diff --git a/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js b/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js index 611e211..4966356 100644 --- a/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js +++ b/scripts/migrations/2026-03-12-consolidate-indexes.mongosh.js @@ -1,112 +1,112 @@ -"use strict"; - -if (typeof db === "undefined" || db === null) { - throw new Error("This script must be run inside mongosh."); -} - -const CONFIG = { - targetDatabases: [], // Leave empty to run on all discovered tenants - globalDbName: "modl", - serversCollName: "servers" -}; - -const INDEX_DEFINITIONS = [ - { - collection: "system_logs", - indexes: [ - { name: "idx_system_logs_timestamp", keys: { timestamp: -1 } }, - { name: "idx_system_logs_level_timestamp", keys: { level: 1, timestamp: -1 } }, - { name: "idx_system_logs_source_timestamp", keys: { source: 1, timestamp: -1 } } - ] - }, - { - collection: "security_events", - indexes: [ - { name: "idx_security_events_timestamp", keys: { timestamp: -1 } }, - { name: "idx_security_events_severity_timestamp", keys: { severity: 1, timestamp: -1 } } - ] - }, - { - collection: "metric_snapshots", - indexes: [ - { name: "idx_metric_snapshots_date", keys: { date: -1 } } - ] - }, - { - collection: "logs", - indexes: [ - { name: "idx_logs_created", keys: { created: -1 } }, - { name: "idx_logs_source_created", keys: { source: 1, created: -1 } } - ] - }, - { - collection: "migrations", - indexes: [ - { name: "idx_migrations_status_startedAt", keys: { status: 1, startedAt: -1 } } - ] - }, - { - collection: "sessions", - indexes: [ - { name: "idx_sessions_email_expiresAt", keys: { email: 1, expiresAt: 1 } } - ] - } -]; - -async function main() { - print(`\n--- Consolidate Indexes Migration ---`); - - try { - const serversDb = db.getSiblingDB(CONFIG.globalDbName); - const servers = serversDb.getCollection(CONFIG.serversCollName).find({ databaseName: { $exists: true, $ne: null } }).toArray(); - - const tenants = CONFIG.targetDatabases.length > 0 - ? servers.filter(s => CONFIG.targetDatabases.includes(s.databaseName)) - : servers; - - print(`Targeting ${tenants.length} tenant databases...\n`); - - let globalCreated = 0; - let globalSkipped = 0; - - for (const tenant of tenants) { - const result = ensureIndexesForTenant(tenant.databaseName); - globalCreated += result.created; - globalSkipped += result.skipped; - print(`[${tenant.databaseName}] Created: ${result.created} | Already existed: ${result.skipped}`); - } - - print(`\n--- GLOBAL SUMMARY ---`); - print(`Total indexes created: ${globalCreated}`); - print(`Total indexes already existed: ${globalSkipped}`); - print(`----------------------\n`); - - } catch (error) { - print(`\n[FATAL ERROR] Script execution failed: ${error.message}`); - } -} - -function ensureIndexesForTenant(databaseName) { - const tenantDb = db.getSiblingDB(databaseName); - let created = 0; - let skipped = 0; - - for (const def of INDEX_DEFINITIONS) { - const coll = tenantDb.getCollection(def.collection); - const existing = coll.getIndexes().map(idx => idx.name); - - for (const idx of def.indexes) { - if (existing.includes(idx.name)) { - skipped++; - continue; - } - - coll.createIndex(idx.keys, { name: idx.name }); - created++; - } - } - - return { created, skipped }; -} - -main(); +"use strict"; + +if (typeof db === "undefined" || db === null) { + throw new Error("This script must be run inside mongosh."); +} + +const CONFIG = { + targetDatabases: [], // Leave empty to run on all discovered tenants + globalDbName: "modl", + serversCollName: "servers" +}; + +const INDEX_DEFINITIONS = [ + { + collection: "system_logs", + indexes: [ + { name: "idx_system_logs_timestamp", keys: { timestamp: -1 } }, + { name: "idx_system_logs_level_timestamp", keys: { level: 1, timestamp: -1 } }, + { name: "idx_system_logs_source_timestamp", keys: { source: 1, timestamp: -1 } } + ] + }, + { + collection: "security_events", + indexes: [ + { name: "idx_security_events_timestamp", keys: { timestamp: -1 } }, + { name: "idx_security_events_severity_timestamp", keys: { severity: 1, timestamp: -1 } } + ] + }, + { + collection: "metric_snapshots", + indexes: [ + { name: "idx_metric_snapshots_date", keys: { date: -1 } } + ] + }, + { + collection: "logs", + indexes: [ + { name: "idx_logs_created", keys: { created: -1 } }, + { name: "idx_logs_source_created", keys: { source: 1, created: -1 } } + ] + }, + { + collection: "migrations", + indexes: [ + { name: "idx_migrations_status_startedAt", keys: { status: 1, startedAt: -1 } } + ] + }, + { + collection: "sessions", + indexes: [ + { name: "idx_sessions_email_expiresAt", keys: { email: 1, expiresAt: 1 } } + ] + } +]; + +async function main() { + print(`\n--- Consolidate Indexes Migration ---`); + + try { + const serversDb = db.getSiblingDB(CONFIG.globalDbName); + const servers = serversDb.getCollection(CONFIG.serversCollName).find({ databaseName: { $exists: true, $ne: null } }).toArray(); + + const tenants = CONFIG.targetDatabases.length > 0 + ? servers.filter(s => CONFIG.targetDatabases.includes(s.databaseName)) + : servers; + + print(`Targeting ${tenants.length} tenant databases...\n`); + + let globalCreated = 0; + let globalSkipped = 0; + + for (const tenant of tenants) { + const result = ensureIndexesForTenant(tenant.databaseName); + globalCreated += result.created; + globalSkipped += result.skipped; + print(`[${tenant.databaseName}] Created: ${result.created} | Already existed: ${result.skipped}`); + } + + print(`\n--- GLOBAL SUMMARY ---`); + print(`Total indexes created: ${globalCreated}`); + print(`Total indexes already existed: ${globalSkipped}`); + print(`----------------------\n`); + + } catch (error) { + print(`\n[FATAL ERROR] Script execution failed: ${error.message}`); + } +} + +function ensureIndexesForTenant(databaseName) { + const tenantDb = db.getSiblingDB(databaseName); + let created = 0; + let skipped = 0; + + for (const def of INDEX_DEFINITIONS) { + const coll = tenantDb.getCollection(def.collection); + const existing = coll.getIndexes().map(idx => idx.name); + + for (const idx of def.indexes) { + if (existing.includes(idx.name)) { + skipped++; + continue; + } + + coll.createIndex(idx.keys, { name: idx.name }); + created++; + } + } + + return { created, skipped }; +} + +main(); diff --git a/src/main/java/gg/modl/backend/BackendApplication.java b/src/main/java/gg/modl/backend/BackendApplication.java index cb98768..769fb3f 100644 --- a/src/main/java/gg/modl/backend/BackendApplication.java +++ b/src/main/java/gg/modl/backend/BackendApplication.java @@ -1,30 +1,30 @@ -package gg.modl.backend; - -import io.github.cdimascio.dotenv.Dotenv; -import io.github.cdimascio.dotenv.DotenvException; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.context.properties.ConfigurationPropertiesScan; - -@SpringBootApplication -@ConfigurationPropertiesScan -public class BackendApplication { - public static void main(String[] args) { - loadDotenvIntoSystemProperties(); - - SpringApplication.run(BackendApplication.class, args); - } - - private static void loadDotenvIntoSystemProperties() { - try { - final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); - dotenv.entries(Dotenv.Filter.DECLARED_IN_ENV_FILE).forEach(entry -> - System.setProperty(entry.getKey(), entry.getValue()) - ); - } catch (DotenvException e) { - System.err.println("[BackendApplication] Could not read .env file (it may be a " - + "directory or unreadable, e.g. an empty bind-mount). Continuing with " - + "environment/-e variables only. Cause: " + e.getMessage()); - } - } -} +package gg.modl.backend; + +import io.github.cdimascio.dotenv.Dotenv; +import io.github.cdimascio.dotenv.DotenvException; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class BackendApplication { + public static void main(String[] args) { + loadDotenvIntoSystemProperties(); + + SpringApplication.run(BackendApplication.class, args); + } + + private static void loadDotenvIntoSystemProperties() { + try { + final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + dotenv.entries(Dotenv.Filter.DECLARED_IN_ENV_FILE).forEach(entry -> + System.setProperty(entry.getKey(), entry.getValue()) + ); + } catch (DotenvException e) { + System.err.println("[BackendApplication] Could not read .env file (it may be a " + + "directory or unreadable, e.g. an empty bind-mount). Continuing with " + + "environment/-e variables only. Cause: " + e.getMessage()); + } + } +} diff --git a/src/main/java/gg/modl/backend/Constants.java b/src/main/java/gg/modl/backend/Constants.java index d986843..2173c32 100644 --- a/src/main/java/gg/modl/backend/Constants.java +++ b/src/main/java/gg/modl/backend/Constants.java @@ -1,20 +1,20 @@ -package gg.modl.backend; - -public final class Constants { - public static final String BRAND_NAME = "modl.gg"; - - private Constants() {} - - public static final class Domain { - public static final String API = "api.modl.gg"; - public static final String ADMIN = "admin.modl.gg"; - public static final String HTTPS_ADMIN = "https://" + ADMIN; - private Domain() {} - } - - public static final class Email { - public static final String ADMIN = "admin@modl.gg"; - - private Email() {} - } -} +package gg.modl.backend; + +public final class Constants { + public static final String BRAND_NAME = "modl.gg"; + + private Constants() {} + + public static final class Domain { + public static final String API = "api.modl.gg"; + public static final String ADMIN = "admin.modl.gg"; + public static final String HTTPS_ADMIN = "https://" + ADMIN; + private Domain() {} + } + + public static final class Email { + public static final String ADMIN = "admin@modl.gg"; + + private Email() {} + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java index 45d18ce..ffb8a70 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java @@ -1,80 +1,80 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.service.AdminAnalyticsService; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.proto.modl.v1.AdminAnalyticsReportResponse; -import gg.modl.proto.modl.v1.ExportAnalyticsRequest; -import gg.modl.proto.modl.v1.GenerateReportRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -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; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_ANALYTICS) -@RequiredArgsConstructor -public class AdminAnalyticsController { - private final AdminAnalyticsService adminAnalyticsService; - - @GetMapping("/dashboard") - public ResponseEntity getDashboard(@RequestParam(defaultValue = "30d") String range) { - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toDashboardResponse(adminAnalyticsService.getDashboard(range))); - } - - @GetMapping("/activity") - public ResponseEntity getActivity(@RequestParam(defaultValue = "30d") String range) { - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toActivityResponse(adminAnalyticsService.getActivity(range))); - } - - @GetMapping("/usage") - public ResponseEntity getUsage() { - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toUsageResponse(adminAnalyticsService.getUsage())); - } - - @GetMapping("/historical") - public ResponseEntity getHistorical( - @RequestParam(required = false) String metric, - @RequestParam(defaultValue = "30d") String range) { - Map response = adminAnalyticsService.getHistorical(metric, range); - if (Boolean.FALSE.equals(response.get("success"))) { - throw new ValidationException(String.valueOf(response.get("error"))); - } - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toHistoricalResponse(response)); - } - - @PostMapping("/export") - public ResponseEntity exportAnalytics(@RequestBody ExportAnalyticsRequest request) { - String type = request.hasType() ? request.getType() : "json"; - String range = request.hasRange() ? request.getRange() : "30d"; - - Object result = adminAnalyticsService.exportAnalytics(type, range); - - if ("csv".equals(type)) { - return ResponseEntity.ok() - .header("Content-Type", "text/csv") - .header("Content-Disposition", "attachment; filename=\"modl-analytics-" + range + ".csv\"") - .body(result); - } else if ("json".equals(type)) { - @SuppressWarnings("unchecked") - Map jsonResult = (Map) result; - return ResponseEntity.ok(AdminAnalyticsProtoMapper.toExportResponse(jsonResult)); - } - - throw new ValidationException("Invalid export type"); - } - - @PostMapping("/report") - public ResponseEntity generateReport(@RequestBody GenerateReportRequest request) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(AdminAnalyticsReportResponse.newBuilder() - .setSuccess(false) - .setError("Report generation not implemented") - .build()); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.service.AdminAnalyticsService; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.proto.modl.v1.AdminAnalyticsReportResponse; +import gg.modl.proto.modl.v1.ExportAnalyticsRequest; +import gg.modl.proto.modl.v1.GenerateReportRequest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_ANALYTICS) +@RequiredArgsConstructor +public class AdminAnalyticsController { + private final AdminAnalyticsService adminAnalyticsService; + + @GetMapping("/dashboard") + public ResponseEntity getDashboard(@RequestParam(defaultValue = "30d") String range) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toDashboardResponse(adminAnalyticsService.getDashboard(range))); + } + + @GetMapping("/activity") + public ResponseEntity getActivity(@RequestParam(defaultValue = "30d") String range) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toActivityResponse(adminAnalyticsService.getActivity(range))); + } + + @GetMapping("/usage") + public ResponseEntity getUsage() { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toUsageResponse(adminAnalyticsService.getUsage())); + } + + @GetMapping("/historical") + public ResponseEntity getHistorical( + @RequestParam(required = false) String metric, + @RequestParam(defaultValue = "30d") String range) { + Map response = adminAnalyticsService.getHistorical(metric, range); + if (Boolean.FALSE.equals(response.get("success"))) { + throw new ValidationException(String.valueOf(response.get("error"))); + } + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toHistoricalResponse(response)); + } + + @PostMapping("/export") + public ResponseEntity exportAnalytics(@RequestBody ExportAnalyticsRequest request) { + String type = request.hasType() ? request.getType() : "json"; + String range = request.hasRange() ? request.getRange() : "30d"; + + Object result = adminAnalyticsService.exportAnalytics(type, range); + + if ("csv".equals(type)) { + return ResponseEntity.ok() + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=\"modl-analytics-" + range + ".csv\"") + .body(result); + } else if ("json".equals(type)) { + @SuppressWarnings("unchecked") + Map jsonResult = (Map) result; + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toExportResponse(jsonResult)); + } + + throw new ValidationException("Invalid export type"); + } + + @PostMapping("/report") + public ResponseEntity generateReport(@RequestBody GenerateReportRequest request) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).body(AdminAnalyticsReportResponse.newBuilder() + .setSuccess(false) + .setError("Report generation not implemented") + .build()); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java b/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java index b6b5326..e32ece9 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java @@ -1,111 +1,111 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.data.AdminUser; -import gg.modl.backend.admin.service.AdminAuthService; -import gg.modl.backend.auth.AuthService; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RESTSecurityRole; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.proto.modl.v1.AdminLoginRequest; -import gg.modl.proto.modl.v1.AdminRequestCodeRequest; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -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; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_AUTH) -@RequiredArgsConstructor -public class AdminAuthController { - private final AdminAuthService adminAuthService; - private final AuthService authService; - private final SessionService sessionService; - private final CookieUtil cookieUtil; - private static final long SESSION_MAX_AGE = 24 * 60 * 60; // 24 hours - - @PostMapping("/request-code") - public ResponseEntity requestCode(@RequestBody AdminRequestCodeRequest request) throws Exception { - - Optional adminOpt = adminAuthService.findByEmail(request.getEmail()); - if (adminOpt.isPresent()) { - authService.sendAdminLoginCode(request.getEmail()); - } - - return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "If this email is registered, a verification code has been sent")); - } - - @PostMapping("/login") - public ResponseEntity login( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody AdminLoginRequest loginRequest) { - - // Always verify code regardless of user existence to prevent timing-based enumeration - boolean codeValid = authService.verifyAdminCode(loginRequest.getEmail(), loginRequest.getCode()); - Optional adminOpt = adminAuthService.findByEmail(loginRequest.getEmail()); - - if (adminOpt.isEmpty() || !codeValid) { - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Invalid credentials")); - } - - AdminUser admin = adminOpt.get(); - String clientIp = RequestUtil.getClientIp(request); - adminAuthService.updateLastActivity(admin.getEmail(), clientIp); - - AuthSessionData session = sessionService.createAdminSession(admin.getEmail()); - - response.addCookie(cookieUtil.createSessionCookie(RESTSecurityRole.ADMIN_SESSION_COOKIE, session.getId(), SESSION_MAX_AGE)); - - return ResponseEntity.ok(AdminAuthProtoMapper.toLoginResponse(true, "Login successful", admin)); - } - - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { - for (String sessionId : adminAuthService.extractSessionIds(request)) { - sessionService.invalidateAdminSession(sessionId); - } - - for (Cookie expiredCookie : cookieUtil.createExpiredSessionCookies(RESTSecurityRole.ADMIN_SESSION_COOKIE)) { - response.addCookie(expiredCookie); - } - - return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "Logout successful")); - } - - @GetMapping("/session") - public ResponseEntity getSession(HttpServletRequest request) { - String sessionId = adminAuthService.extractSessionId(request); - if (sessionId == null) { - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Optional sessionOpt = sessionService.findAndRefreshAdminSession(sessionId); - if (sessionOpt.isEmpty()) { - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); - } - AuthSessionData session = sessionOpt.get(); - if (adminAuthService.isAdminSessionExpired(session)) { - sessionService.invalidateAdminSession(sessionId); - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); - } - - Optional adminOpt = adminAuthService.findByEmail(session.getEmail()); - if (adminOpt.isEmpty()) { - sessionService.invalidateAdminSession(sessionId); - return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "User not found")); - } - - AdminUser admin = adminOpt.get(); - return ResponseEntity.ok(AdminAuthProtoMapper.toSessionResponse(true, admin)); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.data.AdminUser; +import gg.modl.backend.admin.service.AdminAuthService; +import gg.modl.backend.auth.AuthService; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.proto.modl.v1.AdminLoginRequest; +import gg.modl.proto.modl.v1.AdminRequestCodeRequest; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_AUTH) +@RequiredArgsConstructor +public class AdminAuthController { + private final AdminAuthService adminAuthService; + private final AuthService authService; + private final SessionService sessionService; + private final CookieUtil cookieUtil; + private static final long SESSION_MAX_AGE = 24 * 60 * 60; // 24 hours + + @PostMapping("/request-code") + public ResponseEntity requestCode(@RequestBody AdminRequestCodeRequest request) throws Exception { + + Optional adminOpt = adminAuthService.findByEmail(request.getEmail()); + if (adminOpt.isPresent()) { + authService.sendAdminLoginCode(request.getEmail()); + } + + return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "If this email is registered, a verification code has been sent")); + } + + @PostMapping("/login") + public ResponseEntity login( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody AdminLoginRequest loginRequest) { + + // Always verify code regardless of user existence to prevent timing-based enumeration + boolean codeValid = authService.verifyAdminCode(loginRequest.getEmail(), loginRequest.getCode()); + Optional adminOpt = adminAuthService.findByEmail(loginRequest.getEmail()); + + if (adminOpt.isEmpty() || !codeValid) { + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Invalid credentials")); + } + + AdminUser admin = adminOpt.get(); + String clientIp = RequestUtil.getClientIp(request); + adminAuthService.updateLastActivity(admin.getEmail(), clientIp); + + AuthSessionData session = sessionService.createAdminSession(admin.getEmail()); + + response.addCookie(cookieUtil.createSessionCookie(RESTSecurityRole.ADMIN_SESSION_COOKIE, session.getId(), SESSION_MAX_AGE)); + + return ResponseEntity.ok(AdminAuthProtoMapper.toLoginResponse(true, "Login successful", admin)); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + for (String sessionId : adminAuthService.extractSessionIds(request)) { + sessionService.invalidateAdminSession(sessionId); + } + + for (Cookie expiredCookie : cookieUtil.createExpiredSessionCookies(RESTSecurityRole.ADMIN_SESSION_COOKIE)) { + response.addCookie(expiredCookie); + } + + return ResponseEntity.ok(AdminAuthProtoMapper.toAuthResponse(true, "Logout successful")); + } + + @GetMapping("/session") + public ResponseEntity getSession(HttpServletRequest request) { + String sessionId = adminAuthService.extractSessionId(request); + if (sessionId == null) { + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Optional sessionOpt = sessionService.findAndRefreshAdminSession(sessionId); + if (sessionOpt.isEmpty()) { + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); + } + AuthSessionData session = sessionOpt.get(); + if (adminAuthService.isAdminSessionExpired(session)) { + sessionService.invalidateAdminSession(sessionId); + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "Session expired")); + } + + Optional adminOpt = adminAuthService.findByEmail(session.getEmail()); + if (adminOpt.isEmpty()) { + sessionService.invalidateAdminSession(sessionId); + return ResponseEntity.status(401).body(AdminAuthProtoMapper.toAuthResponse(false, "User not found")); + } + + AdminUser admin = adminOpt.get(); + return ResponseEntity.ok(AdminAuthProtoMapper.toSessionResponse(true, admin)); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java index 3dae7ae..f6fa673 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java @@ -1,139 +1,139 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.data.SystemLog; -import gg.modl.backend.admin.service.AdminMonitoringService; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_MONITORING) -@RequiredArgsConstructor -@Validated -public class AdminMonitoringController { - private final AdminMonitoringService adminMonitoringService; - - @GetMapping("/dashboard") - public ResponseEntity getDashboard() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toDashboardResponse(adminMonitoringService.getDashboard())); - } - - @GetMapping("/logs") - public ResponseEntity getLogs( - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(required = false) String level, - @RequestParam(required = false) String source, - @RequestParam(required = false) String serverId, - @RequestParam(required = false) String category, - @RequestParam(required = false) String resolved, - @RequestParam(required = false) String search, - @RequestParam(required = false) String startDate, - @RequestParam(required = false) String endDate, - @RequestParam(defaultValue = "timestamp") String sort, - @RequestParam(defaultValue = "desc") String order) { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toLogsResponse(adminMonitoringService.getLogs( - page, - limit, - level, - source, - serverId, - category, - resolved, - search, - startDate, - endDate, - sort, - order - ))); - } - - @PostMapping("/logs") - public ResponseEntity createLog(@RequestBody gg.modl.proto.modl.v1.CreateSystemLogRequest request) { - SystemLog saved = adminMonitoringService.createLog(AdminMonitoringProtoMapper.fromCreateLog(request)); - return ResponseEntity.status(201).body( - AdminMonitoringProtoMapper.toSystemLogMutationResponse(saved, "Log entry created successfully")); - } - - @GetMapping("/sources") - public ResponseEntity getSources() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toSourcesResponse(adminMonitoringService.getSources())); - } - - @PutMapping("/logs/{id}/resolve") - public ResponseEntity resolveLog(@PathVariable String id, @RequestBody gg.modl.proto.modl.v1.ResolveLogRequest request) { - SystemLog updated = adminMonitoringService.resolveLog(id, AdminMonitoringProtoMapper.fromResolveLog(request)).orElse(null); - if (updated == null) { - throw new ResourceNotFoundException("Log entry not found"); - } - - return ResponseEntity.ok(AdminMonitoringProtoMapper.toSystemLogMutationResponse(updated, "Log entry marked as resolved")); - } - - @GetMapping("/health") - public ResponseEntity getHealth() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toHealthResponse(adminMonitoringService.getHealth())); - } - - @PostMapping("/logs/delete") - public ResponseEntity deleteLogs(@RequestBody gg.modl.proto.modl.v1.DeleteLogsRequest request) { - long deletedCount = adminMonitoringService.deleteLogs(request.getLogIdsList()); - return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse( - deletedCount, "Successfully deleted " + deletedCount + " log(s)")); - } - - @GetMapping("/logs/export") - public ResponseEntity exportLogs( - @RequestParam(required = false) String level, - @RequestParam(required = false) String source, - @RequestParam(required = false) String category, - @RequestParam(required = false) String resolved, - @RequestParam(required = false) String search, - @RequestParam(required = false) String startDate, - @RequestParam(required = false) String endDate) { - String csv = adminMonitoringService.exportLogs(level, source, category, resolved, search, startDate, endDate); - - return ResponseEntity.ok() - .header("Content-Type", "text/csv") - .header("Content-Disposition", "attachment; filename=\"system-logs-" + new Date().toInstant().toString().split("T")[0] + ".csv\"") - .body(csv); - } - - @PostMapping("/logs/clear-all") - public ResponseEntity clearAllLogs() { - long deletedCount = adminMonitoringService.clearAllLogs(); - return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse(deletedCount, "Successfully cleared all logs")); - } - - @GetMapping("/pm2-status") - public ResponseEntity getPm2Status() { - // PM2 integration placeholder - would require native process monitoring - return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2StatusResponse()); - } - - @PostMapping("/pm2/restart") - public ResponseEntity restartPm2() { - return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2RestartResponse("PM2 log streaming restarted")); - } - - @PostMapping("/pm2/toggle") - public ResponseEntity togglePm2(@RequestBody gg.modl.proto.modl.v1.TogglePm2Request request) { - boolean enabled = request.hasEnabledValue() ? request.getEnabledValue() : request.getEnabled(); - return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2ToggleResponse( - enabled, "PM2 log streaming " + (enabled ? "enabled" : "disabled"))); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.data.SystemLog; +import gg.modl.backend.admin.service.AdminMonitoringService; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_MONITORING) +@RequiredArgsConstructor +@Validated +public class AdminMonitoringController { + private final AdminMonitoringService adminMonitoringService; + + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toDashboardResponse(adminMonitoringService.getDashboard())); + } + + @GetMapping("/logs") + public ResponseEntity getLogs( + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(required = false) String level, + @RequestParam(required = false) String source, + @RequestParam(required = false) String serverId, + @RequestParam(required = false) String category, + @RequestParam(required = false) String resolved, + @RequestParam(required = false) String search, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate, + @RequestParam(defaultValue = "timestamp") String sort, + @RequestParam(defaultValue = "desc") String order) { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toLogsResponse(adminMonitoringService.getLogs( + page, + limit, + level, + source, + serverId, + category, + resolved, + search, + startDate, + endDate, + sort, + order + ))); + } + + @PostMapping("/logs") + public ResponseEntity createLog(@RequestBody gg.modl.proto.modl.v1.CreateSystemLogRequest request) { + SystemLog saved = adminMonitoringService.createLog(AdminMonitoringProtoMapper.fromCreateLog(request)); + return ResponseEntity.status(201).body( + AdminMonitoringProtoMapper.toSystemLogMutationResponse(saved, "Log entry created successfully")); + } + + @GetMapping("/sources") + public ResponseEntity getSources() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toSourcesResponse(adminMonitoringService.getSources())); + } + + @PutMapping("/logs/{id}/resolve") + public ResponseEntity resolveLog(@PathVariable String id, @RequestBody gg.modl.proto.modl.v1.ResolveLogRequest request) { + SystemLog updated = adminMonitoringService.resolveLog(id, AdminMonitoringProtoMapper.fromResolveLog(request)).orElse(null); + if (updated == null) { + throw new ResourceNotFoundException("Log entry not found"); + } + + return ResponseEntity.ok(AdminMonitoringProtoMapper.toSystemLogMutationResponse(updated, "Log entry marked as resolved")); + } + + @GetMapping("/health") + public ResponseEntity getHealth() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toHealthResponse(adminMonitoringService.getHealth())); + } + + @PostMapping("/logs/delete") + public ResponseEntity deleteLogs(@RequestBody gg.modl.proto.modl.v1.DeleteLogsRequest request) { + long deletedCount = adminMonitoringService.deleteLogs(request.getLogIdsList()); + return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse( + deletedCount, "Successfully deleted " + deletedCount + " log(s)")); + } + + @GetMapping("/logs/export") + public ResponseEntity exportLogs( + @RequestParam(required = false) String level, + @RequestParam(required = false) String source, + @RequestParam(required = false) String category, + @RequestParam(required = false) String resolved, + @RequestParam(required = false) String search, + @RequestParam(required = false) String startDate, + @RequestParam(required = false) String endDate) { + String csv = adminMonitoringService.exportLogs(level, source, category, resolved, search, startDate, endDate); + + return ResponseEntity.ok() + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=\"system-logs-" + new Date().toInstant().toString().split("T")[0] + ".csv\"") + .body(csv); + } + + @PostMapping("/logs/clear-all") + public ResponseEntity clearAllLogs() { + long deletedCount = adminMonitoringService.clearAllLogs(); + return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse(deletedCount, "Successfully cleared all logs")); + } + + @GetMapping("/pm2-status") + public ResponseEntity getPm2Status() { + // PM2 integration placeholder - would require native process monitoring + return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2StatusResponse()); + } + + @PostMapping("/pm2/restart") + public ResponseEntity restartPm2() { + return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2RestartResponse("PM2 log streaming restarted")); + } + + @PostMapping("/pm2/toggle") + public ResponseEntity togglePm2(@RequestBody gg.modl.proto.modl.v1.TogglePm2Request request) { + boolean enabled = request.hasEnabledValue() ? request.getEnabledValue() : request.getEnabled(); + return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2ToggleResponse( + enabled, "PM2 log streaming " + (enabled ? "enabled" : "disabled"))); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java b/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java index 7cb0898..85eaa49 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java @@ -1,256 +1,256 @@ -package gg.modl.backend.admin.controller; - -import com.google.protobuf.Timestamp; -import gg.modl.backend.admin.service.AdminServerService; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.proto.modl.v1.AdminServerBulkOperationRequest; -import gg.modl.proto.modl.v1.AdminServerCreateRequest; -import gg.modl.proto.modl.v1.AdminServerExportRequest; -import gg.modl.proto.modl.v1.AdminServerSearchRequest; -import gg.modl.proto.modl.v1.AdminServerUpdateStatsRequest; -import gg.modl.proto.modl.v1.UpdateServerRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.annotation.Validated; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_SERVERS) -@RequiredArgsConstructor -@Slf4j -@Validated -public class AdminServerController { - private final AdminServerService serverService; - - @GetMapping - public ResponseEntity getServers( - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(required = false) String search, - @RequestParam(required = false) String plan, - @RequestParam(required = false) String status, - @RequestParam(defaultValue = "createdAt") String sort, - @RequestParam(defaultValue = "desc") String order) { - - int pageNum = PaginationHelper.normalizePage(page); - int limitNum = PaginationHelper.normalizeLimit(limit, 50); - int skip = PaginationHelper.calculateSkip(page, limitNum); - - List servers = serverService.findServers(search, plan, status, sort, order, skip, limitNum); - long total = serverService.countServers(search, plan, status); - - return ResponseEntity.ok(AdminServerProtoMapper.toListResponse( - servers, pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum))); - } - - @PostMapping("/usage/batch") - public ResponseEntity getUsageBatch(@RequestBody gg.modl.proto.modl.v1.AdminServerUsageBatchRequest request) { - List serverIds = request.getServerIdsList(); - if (serverIds.isEmpty()) { - throw new ValidationException("Missing required field: serverIds"); - } - - if (serverIds.size() > 50) { - throw new ValidationException("Maximum 50 server IDs per request"); - } - - boolean forceRefresh = request.getForceRefresh(); - Map usage = serverService.getUsageStatsForServerIds(serverIds, forceRefresh); - - return ResponseEntity.ok(AdminServerProtoMapper.toUsageBatchResponse(usage)); - } - - @GetMapping("/{id}") - public ResponseEntity getServer(@PathVariable String id) { - return serverService.findById(id) - .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toDetailResponse(server))) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @GetMapping("/{id}/stats") - public ResponseEntity getServerStats(@PathVariable String id) { - return serverService.findById(id) - .map(server -> { - Map stats = serverService.getServerStats(server); - return ResponseEntity.ok((Object) AdminServerProtoMapper.toStatsResponse(stats)); - }) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @PostMapping - public ResponseEntity createServer(@RequestBody AdminServerCreateRequest request) { - Server saved = serverService.createServer( - request.getServerName(), - request.getCustomDomain(), - request.getAdminEmail(), - request.hasPlan() ? request.getPlan() : null); - return ResponseEntity.status(201).body( - AdminServerProtoMapper.toMutationResponse(saved, "Server created successfully")); - } - - @PutMapping("/{id}") - public ResponseEntity updateServer(@PathVariable String id, @RequestBody UpdateServerRequest request) { - Server server = serverService.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - - if (request.hasAdminEmail()) { - serverService.changeAdminEmail(server, request.getAdminEmail()); - } - - Map updateData = new HashMap<>(); - if (request.hasEmailVerified()) { - updateData.put("emailVerified", request.getEmailVerified()); - } - if (request.hasProvisioningStatus()) { - updateData.put("provisioningStatus", request.getProvisioningStatus()); - } - if (request.hasProvisioningNotes()) { - updateData.put("provisioningNotes", request.getProvisioningNotes()); - } - if (request.hasPlan()) { - updateData.put("plan", request.getPlan()); - } - if (request.hasSubscriptionStatus()) { - updateData.put("subscriptionStatus", request.getSubscriptionStatus()); - } - if (request.hasLastActivityAt()) { - updateData.put("lastActivityAt", request.getLastActivityAt()); - } - updateData.put("updatedAt", new Date()); - - Server updated = serverService.updateById(id, updateData); - if (updated != null) { - return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server updated successfully")); - } - throw new ValidationException("Failed to update server"); - } - - @PutMapping("/{id}/stats") - public ResponseEntity updateServerStats(@PathVariable String id, @RequestBody AdminServerUpdateStatsRequest request) { - if (serverService.findById(id).isEmpty()) { - throw new ResourceNotFoundException("Server not found"); - } - - Map updateData = new HashMap<>(); - if (request.hasLastActivityAt()) { - updateData.put("lastActivityAt", toDate(request.getLastActivityAt())); - } - updateData.put("updatedAt", new Date()); - - Server updated = serverService.updateById(id, updateData); - if (updated != null) { - return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server activity updated successfully")); - } - throw new ValidationException("Failed to update server"); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteServer(@PathVariable String id) { - if (serverService.deleteById(id)) { - return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(null, "Server deleted successfully")); - } - throw new ResourceNotFoundException("Server not found"); - } - - @PostMapping("/bulk") - public ResponseEntity bulkOperation(@RequestBody AdminServerBulkOperationRequest request) { - List serverIds = request.getServerIdsList(); - if (request.getAction().isEmpty() || serverIds.isEmpty()) { - throw new ValidationException("Missing required fields: action, serverIds"); - } - - String action = request.getAction(); - long affectedCount = switch (action) { - case "delete" -> serverService.bulkDelete(serverIds); - case "suspend" -> serverService.bulkSuspend(serverIds); - case "activate" -> serverService.bulkActivate(serverIds); - case "update-plan" -> { - if (!request.hasParameters() || !request.getParameters().hasPlan()) { - yield -1L; - } - yield serverService.bulkUpdatePlan(serverIds, request.getParameters().getPlan()); - } - default -> -1L; - }; - - if (affectedCount < 0) { - throw new ValidationException("Invalid action or missing parameters"); - } - - return ResponseEntity.ok(AdminServerProtoMapper.toBulkOperationResponse( - action, affectedCount, serverIds, "Bulk operation '" + action + "' completed successfully")); - } - - @PostMapping("/{id}/reset-database") - public ResponseEntity resetDatabase(@PathVariable String id) { - return serverService.findById(id) - .map(server -> { - serverService.resetServerDatabase(server); - log.info("Server {} reset to provisioning state by admin", server.getServerName()); - return ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, - "Server reset to provisioning state. The provisioning system will reinitialize the database.")); - }) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @PostMapping("/{id}/export-data") - public ResponseEntity exportData(@PathVariable String id) { - return serverService.findById(id) - .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, - "Data export initiated. You will receive an email with the download link."))) - .orElseThrow(() -> new ResourceNotFoundException("Server not found")); - } - - @PostMapping("/search") - public ResponseEntity searchServers(@RequestBody AdminServerSearchRequest request) { - String query = request.hasQuery() ? request.getQuery() : ""; - String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; - String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; - - List servers = serverService.findServers(query, plan, status, "createdAt", "desc", 0, 50); - long total = serverService.countServers(query, plan, status); - - return ResponseEntity.ok(AdminServerProtoMapper.toSearchResponse(servers, total)); - } - - @PostMapping("/export") - public ResponseEntity exportServers(@RequestBody AdminServerExportRequest request) { - String format = request.hasFormat() ? request.getFormat() : "json"; - String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; - String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; - - if ("csv".equalsIgnoreCase(format)) { - return ResponseEntity.ok() - .header("Content-Type", "text/csv") - .header("Content-Disposition", "attachment; filename=servers-export.csv") - .body(serverService.exportServersCsv(plan, status)); - } - - List servers = serverService.findServers(null, plan, status, "createdAt", "desc", 0, 10000); - return ResponseEntity.ok(AdminServerProtoMapper.toExportResponse(servers, new Date(), format, servers.size())); - } - - private static Date toDate(Timestamp timestamp) { - return new Date(timestamp.getSeconds() * 1000L + timestamp.getNanos() / 1_000_000L); - } -} +package gg.modl.backend.admin.controller; + +import com.google.protobuf.Timestamp; +import gg.modl.backend.admin.service.AdminServerService; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.AdminServerBulkOperationRequest; +import gg.modl.proto.modl.v1.AdminServerCreateRequest; +import gg.modl.proto.modl.v1.AdminServerExportRequest; +import gg.modl.proto.modl.v1.AdminServerSearchRequest; +import gg.modl.proto.modl.v1.AdminServerUpdateStatsRequest; +import gg.modl.proto.modl.v1.UpdateServerRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_SERVERS) +@RequiredArgsConstructor +@Slf4j +@Validated +public class AdminServerController { + private final AdminServerService serverService; + + @GetMapping + public ResponseEntity getServers( + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(required = false) String search, + @RequestParam(required = false) String plan, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "createdAt") String sort, + @RequestParam(defaultValue = "desc") String order) { + + int pageNum = PaginationHelper.normalizePage(page); + int limitNum = PaginationHelper.normalizeLimit(limit, 50); + int skip = PaginationHelper.calculateSkip(page, limitNum); + + List servers = serverService.findServers(search, plan, status, sort, order, skip, limitNum); + long total = serverService.countServers(search, plan, status); + + return ResponseEntity.ok(AdminServerProtoMapper.toListResponse( + servers, pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum))); + } + + @PostMapping("/usage/batch") + public ResponseEntity getUsageBatch(@RequestBody gg.modl.proto.modl.v1.AdminServerUsageBatchRequest request) { + List serverIds = request.getServerIdsList(); + if (serverIds.isEmpty()) { + throw new ValidationException("Missing required field: serverIds"); + } + + if (serverIds.size() > 50) { + throw new ValidationException("Maximum 50 server IDs per request"); + } + + boolean forceRefresh = request.getForceRefresh(); + Map usage = serverService.getUsageStatsForServerIds(serverIds, forceRefresh); + + return ResponseEntity.ok(AdminServerProtoMapper.toUsageBatchResponse(usage)); + } + + @GetMapping("/{id}") + public ResponseEntity getServer(@PathVariable String id) { + return serverService.findById(id) + .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toDetailResponse(server))) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @GetMapping("/{id}/stats") + public ResponseEntity getServerStats(@PathVariable String id) { + return serverService.findById(id) + .map(server -> { + Map stats = serverService.getServerStats(server); + return ResponseEntity.ok((Object) AdminServerProtoMapper.toStatsResponse(stats)); + }) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @PostMapping + public ResponseEntity createServer(@RequestBody AdminServerCreateRequest request) { + Server saved = serverService.createServer( + request.getServerName(), + request.getCustomDomain(), + request.getAdminEmail(), + request.hasPlan() ? request.getPlan() : null); + return ResponseEntity.status(201).body( + AdminServerProtoMapper.toMutationResponse(saved, "Server created successfully")); + } + + @PutMapping("/{id}") + public ResponseEntity updateServer(@PathVariable String id, @RequestBody UpdateServerRequest request) { + Server server = serverService.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + + if (request.hasAdminEmail()) { + serverService.changeAdminEmail(server, request.getAdminEmail()); + } + + Map updateData = new HashMap<>(); + if (request.hasEmailVerified()) { + updateData.put("emailVerified", request.getEmailVerified()); + } + if (request.hasProvisioningStatus()) { + updateData.put("provisioningStatus", request.getProvisioningStatus()); + } + if (request.hasProvisioningNotes()) { + updateData.put("provisioningNotes", request.getProvisioningNotes()); + } + if (request.hasPlan()) { + updateData.put("plan", request.getPlan()); + } + if (request.hasSubscriptionStatus()) { + updateData.put("subscriptionStatus", request.getSubscriptionStatus()); + } + if (request.hasLastActivityAt()) { + updateData.put("lastActivityAt", request.getLastActivityAt()); + } + updateData.put("updatedAt", new Date()); + + Server updated = serverService.updateById(id, updateData); + if (updated != null) { + return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server updated successfully")); + } + throw new ValidationException("Failed to update server"); + } + + @PutMapping("/{id}/stats") + public ResponseEntity updateServerStats(@PathVariable String id, @RequestBody AdminServerUpdateStatsRequest request) { + if (serverService.findById(id).isEmpty()) { + throw new ResourceNotFoundException("Server not found"); + } + + Map updateData = new HashMap<>(); + if (request.hasLastActivityAt()) { + updateData.put("lastActivityAt", toDate(request.getLastActivityAt())); + } + updateData.put("updatedAt", new Date()); + + Server updated = serverService.updateById(id, updateData); + if (updated != null) { + return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(updated, "Server activity updated successfully")); + } + throw new ValidationException("Failed to update server"); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteServer(@PathVariable String id) { + if (serverService.deleteById(id)) { + return ResponseEntity.ok(AdminServerProtoMapper.toMutationResponse(null, "Server deleted successfully")); + } + throw new ResourceNotFoundException("Server not found"); + } + + @PostMapping("/bulk") + public ResponseEntity bulkOperation(@RequestBody AdminServerBulkOperationRequest request) { + List serverIds = request.getServerIdsList(); + if (request.getAction().isEmpty() || serverIds.isEmpty()) { + throw new ValidationException("Missing required fields: action, serverIds"); + } + + String action = request.getAction(); + long affectedCount = switch (action) { + case "delete" -> serverService.bulkDelete(serverIds); + case "suspend" -> serverService.bulkSuspend(serverIds); + case "activate" -> serverService.bulkActivate(serverIds); + case "update-plan" -> { + if (!request.hasParameters() || !request.getParameters().hasPlan()) { + yield -1L; + } + yield serverService.bulkUpdatePlan(serverIds, request.getParameters().getPlan()); + } + default -> -1L; + }; + + if (affectedCount < 0) { + throw new ValidationException("Invalid action or missing parameters"); + } + + return ResponseEntity.ok(AdminServerProtoMapper.toBulkOperationResponse( + action, affectedCount, serverIds, "Bulk operation '" + action + "' completed successfully")); + } + + @PostMapping("/{id}/reset-database") + public ResponseEntity resetDatabase(@PathVariable String id) { + return serverService.findById(id) + .map(server -> { + serverService.resetServerDatabase(server); + log.info("Server {} reset to provisioning state by admin", server.getServerName()); + return ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, + "Server reset to provisioning state. The provisioning system will reinitialize the database.")); + }) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @PostMapping("/{id}/export-data") + public ResponseEntity exportData(@PathVariable String id) { + return serverService.findById(id) + .map(server -> ResponseEntity.ok((Object) AdminServerProtoMapper.toMutationResponse(null, + "Data export initiated. You will receive an email with the download link."))) + .orElseThrow(() -> new ResourceNotFoundException("Server not found")); + } + + @PostMapping("/search") + public ResponseEntity searchServers(@RequestBody AdminServerSearchRequest request) { + String query = request.hasQuery() ? request.getQuery() : ""; + String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; + String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; + + List servers = serverService.findServers(query, plan, status, "createdAt", "desc", 0, 50); + long total = serverService.countServers(query, plan, status); + + return ResponseEntity.ok(AdminServerProtoMapper.toSearchResponse(servers, total)); + } + + @PostMapping("/export") + public ResponseEntity exportServers(@RequestBody AdminServerExportRequest request) { + String format = request.hasFormat() ? request.getFormat() : "json"; + String plan = request.hasFilters() && request.getFilters().hasPlan() ? request.getFilters().getPlan() : null; + String status = request.hasFilters() && request.getFilters().hasStatus() ? request.getFilters().getStatus() : null; + + if ("csv".equalsIgnoreCase(format)) { + return ResponseEntity.ok() + .header("Content-Type", "text/csv") + .header("Content-Disposition", "attachment; filename=servers-export.csv") + .body(serverService.exportServersCsv(plan, status)); + } + + List servers = serverService.findServers(null, plan, status, "createdAt", "desc", 0, 10000); + return ResponseEntity.ok(AdminServerProtoMapper.toExportResponse(servers, new Date(), format, servers.size())); + } + + private static Date toDate(Timestamp timestamp) { + return new Date(timestamp.getSeconds() * 1000L + timestamp.getNanos() / 1_000_000L); + } +} diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java b/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java index 172f2af..b9a8ef0 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java @@ -1,103 +1,103 @@ -package gg.modl.backend.admin.controller; - -import gg.modl.backend.admin.data.SystemConfig; -import gg.modl.backend.admin.data.SystemPrompt; -import gg.modl.backend.admin.dto.request.ToggleMaintenanceRequest; -import gg.modl.backend.admin.service.GlobalSystemService; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import java.util.Date; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.ADMIN_SYSTEM) -@RequiredArgsConstructor -@Slf4j -public class AdminSystemController { - private final GlobalSystemService globalSystemService; - - @GetMapping("/config") - public ResponseEntity getConfig() { - SystemConfig config = globalSystemService.getOrCreateConfig(); - return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(config, null)); - } - - @PutMapping("/config") - public ResponseEntity updateConfig(@RequestBody gg.modl.proto.modl.v1.UpdateSystemConfigRequest request) { - SystemConfig saved = globalSystemService.updateConfig(AdminSystemProtoMapper.fromUpdateConfig(request)); - log.info("Configuration updated by admin"); - return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(saved, "Configuration updated successfully")); - } - - @GetMapping("/maintenance") - public ResponseEntity getMaintenanceStatus() { - return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse(globalSystemService.getMaintenanceStatus(), null)); - } - - @PostMapping("/maintenance/toggle") - public ResponseEntity toggleMaintenance(@RequestBody gg.modl.proto.modl.v1.ToggleMaintenanceRequest request) { - ToggleMaintenanceRequest domainRequest = AdminSystemProtoMapper.fromToggleMaintenance(request); - boolean enabled = domainRequest.enabled(); - Map data = globalSystemService.toggleMaintenance(domainRequest); - log.info("Maintenance mode {} by admin", enabled ? "enabled" : "disabled"); - return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse( - data, "Maintenance mode " + (enabled ? "enabled" : "disabled"))); - } - - @GetMapping("/rate-limits") - public ResponseEntity getRateLimits() { - return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsResponse(globalSystemService.getRateLimitStatus())); - } - - @PutMapping("/rate-limits") - public ResponseEntity updateRateLimits(@RequestBody gg.modl.proto.modl.v1.UpdateRateLimitsRequest request) { - SystemConfig.PerformanceConfig performanceConfig = globalSystemService.updateRateLimits( - AdminSystemProtoMapper.fromUpdateRateLimits(request)); - log.info("Rate limits updated by admin"); - return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsUpdateResponse(performanceConfig, "Rate limits updated successfully")); - } - - @GetMapping("/prompts") - public ResponseEntity getPrompt() { - SystemPrompt prompt = globalSystemService.getPrompt(); - return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(prompt, null)); - } - - @PutMapping("/prompts") - public ResponseEntity updatePrompt(@RequestBody gg.modl.proto.modl.v1.UpdatePromptRequest request) { - SystemPrompt updated = globalSystemService.updatePrompt(AdminSystemProtoMapper.fromUpdatePrompt(request)); - log.info("System prompt updated"); - return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(updated, "System prompt updated successfully")); - } - - @PostMapping("/prompts/reset") - public ResponseEntity resetPrompt() { - SystemPrompt reset = globalSystemService.resetPrompt(); - log.info("System prompt reset to default"); - return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(reset, "System prompt reset to default")); - } - - @PostMapping("/services/{service}/restart") - public ResponseEntity restartService(@PathVariable String service) { - List allowedServices = List.of("api", "worker", "scheduler", "cache", "database"); - if (!allowedServices.contains(service)) { - throw new ValidationException("Invalid service name. Allowed: " + String.join(", ", allowedServices)); - } - - log.info("Service restart requested for: {} by admin", service); - - return ResponseEntity.ok(AdminSystemProtoMapper.toServiceRestartResponse( - service, "restarting", new Date(), "Service " + service + " restart initiated")); - } -} +package gg.modl.backend.admin.controller; + +import gg.modl.backend.admin.data.SystemConfig; +import gg.modl.backend.admin.data.SystemPrompt; +import gg.modl.backend.admin.dto.request.ToggleMaintenanceRequest; +import gg.modl.backend.admin.service.GlobalSystemService; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import java.util.Date; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.ADMIN_SYSTEM) +@RequiredArgsConstructor +@Slf4j +public class AdminSystemController { + private final GlobalSystemService globalSystemService; + + @GetMapping("/config") + public ResponseEntity getConfig() { + SystemConfig config = globalSystemService.getOrCreateConfig(); + return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(config, null)); + } + + @PutMapping("/config") + public ResponseEntity updateConfig(@RequestBody gg.modl.proto.modl.v1.UpdateSystemConfigRequest request) { + SystemConfig saved = globalSystemService.updateConfig(AdminSystemProtoMapper.fromUpdateConfig(request)); + log.info("Configuration updated by admin"); + return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(saved, "Configuration updated successfully")); + } + + @GetMapping("/maintenance") + public ResponseEntity getMaintenanceStatus() { + return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse(globalSystemService.getMaintenanceStatus(), null)); + } + + @PostMapping("/maintenance/toggle") + public ResponseEntity toggleMaintenance(@RequestBody gg.modl.proto.modl.v1.ToggleMaintenanceRequest request) { + ToggleMaintenanceRequest domainRequest = AdminSystemProtoMapper.fromToggleMaintenance(request); + boolean enabled = domainRequest.enabled(); + Map data = globalSystemService.toggleMaintenance(domainRequest); + log.info("Maintenance mode {} by admin", enabled ? "enabled" : "disabled"); + return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse( + data, "Maintenance mode " + (enabled ? "enabled" : "disabled"))); + } + + @GetMapping("/rate-limits") + public ResponseEntity getRateLimits() { + return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsResponse(globalSystemService.getRateLimitStatus())); + } + + @PutMapping("/rate-limits") + public ResponseEntity updateRateLimits(@RequestBody gg.modl.proto.modl.v1.UpdateRateLimitsRequest request) { + SystemConfig.PerformanceConfig performanceConfig = globalSystemService.updateRateLimits( + AdminSystemProtoMapper.fromUpdateRateLimits(request)); + log.info("Rate limits updated by admin"); + return ResponseEntity.ok(AdminSystemProtoMapper.toRateLimitsUpdateResponse(performanceConfig, "Rate limits updated successfully")); + } + + @GetMapping("/prompts") + public ResponseEntity getPrompt() { + SystemPrompt prompt = globalSystemService.getPrompt(); + return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(prompt, null)); + } + + @PutMapping("/prompts") + public ResponseEntity updatePrompt(@RequestBody gg.modl.proto.modl.v1.UpdatePromptRequest request) { + SystemPrompt updated = globalSystemService.updatePrompt(AdminSystemProtoMapper.fromUpdatePrompt(request)); + log.info("System prompt updated"); + return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(updated, "System prompt updated successfully")); + } + + @PostMapping("/prompts/reset") + public ResponseEntity resetPrompt() { + SystemPrompt reset = globalSystemService.resetPrompt(); + log.info("System prompt reset to default"); + return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(reset, "System prompt reset to default")); + } + + @PostMapping("/services/{service}/restart") + public ResponseEntity restartService(@PathVariable String service) { + List allowedServices = List.of("api", "worker", "scheduler", "cache", "database"); + if (!allowedServices.contains(service)) { + throw new ValidationException("Invalid service name. Allowed: " + String.join(", ", allowedServices)); + } + + log.info("Service restart requested for: {} by admin", service); + + return ResponseEntity.ok(AdminSystemProtoMapper.toServiceRestartResponse( + service, "restarting", new Date(), "Service " + service + " restart initiated")); + } +} diff --git a/src/main/java/gg/modl/backend/admin/data/AdminUser.java b/src/main/java/gg/modl/backend/admin/data/AdminUser.java index 2ff143e..1ea0219 100644 --- a/src/main/java/gg/modl/backend/admin/data/AdminUser.java +++ b/src/main/java/gg/modl/backend/admin/data/AdminUser.java @@ -1,37 +1,37 @@ -package gg.modl.backend.admin.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.email.EmailAddressUtil; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Document(collection = "admin_users") -@GenerateMongoFields -public class AdminUser { - @Id - private String id; - @Field("email") - private String email; - @Field("loggedInIps") - private List loggedInIps = new ArrayList<>(); - @Field("lastActivityAt") - private Date lastActivityAt; - @Field("createdAt") - private Date createdAt; - - public AdminUser(String email) { - this(); - this.email = EmailAddressUtil.normalize(email); - } - - public AdminUser() { - this.createdAt = new Date(); - this.lastActivityAt = new Date(); - } -} +package gg.modl.backend.admin.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.email.EmailAddressUtil; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = "admin_users") +@GenerateMongoFields +public class AdminUser { + @Id + private String id; + @Field("email") + private String email; + @Field("loggedInIps") + private List loggedInIps = new ArrayList<>(); + @Field("lastActivityAt") + private Date lastActivityAt; + @Field("createdAt") + private Date createdAt; + + public AdminUser(String email) { + this(); + this.email = EmailAddressUtil.normalize(email); + } + + public AdminUser() { + this.createdAt = new Date(); + this.lastActivityAt = new Date(); + } +} diff --git a/src/main/java/gg/modl/backend/admin/data/SystemConfig.java b/src/main/java/gg/modl/backend/admin/data/SystemConfig.java index 0a590fd..62fa09f 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemConfig.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemConfig.java @@ -1,133 +1,133 @@ -package gg.modl.backend.admin.data; - -import gg.modl.backend.Constants; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Document(collection = "system_config") -@GenerateMongoFields -public class SystemConfig { - @Id - private String id; - @Field("configId") - private String configId = "main_config"; - - @Field("general") - private GeneralConfig general = new GeneralConfig(); - @Field("logging") - private LoggingConfig logging = new LoggingConfig(); - @Field("security") - private SecurityConfig security = new SecurityConfig(); - @Field("notifications") - private NotificationsConfig notifications = new NotificationsConfig(); - @Field("performance") - private PerformanceConfig performance = new PerformanceConfig(); - @Field("features") - private FeaturesConfig features = new FeaturesConfig(); - - @Field("createdAt") - private Date createdAt = new Date(); - @Field("updatedAt") - private Date updatedAt = new Date(); - - @Data - public static class GeneralConfig { - @Field("systemName") - private String systemName = "modl Admin"; - @Field("adminEmail") - private String adminEmail = Constants.Email.ADMIN; - @Field("timezone") - private String timezone = "UTC"; - @Field("defaultLanguage") - private String defaultLanguage = "en"; - @Field("maintenanceMode") - private boolean maintenanceMode = false; - @Field("maintenanceMessage") - private String maintenanceMessage = "System under maintenance. Please check back later."; - } - - @Data - public static class LoggingConfig { - @Field("pm2LoggingEnabled") - private boolean pm2LoggingEnabled = true; - @Field("logRetentionDays") - private int logRetentionDays = 30; - @Field("maxLogSizePerDay") - private int maxLogSizePerDay = 1000000; - } - - @Data - public static class SecurityConfig { - @Field("sessionTimeout") - private int sessionTimeout = 60; - @Field("maxLoginAttempts") - private int maxLoginAttempts = 5; - @Field("lockoutDuration") - private int lockoutDuration = 15; - @Field("requireTwoFactor") - private boolean requireTwoFactor = false; - @Field("passwordMinLength") - private int passwordMinLength = 8; - @Field("passwordRequireSpecial") - private boolean passwordRequireSpecial = false; - @Field("ipWhitelist") - private List ipWhitelist = new ArrayList<>(); - @Field("corsOrigins") - private List corsOrigins = new ArrayList<>(List.of(Constants.Domain.HTTPS_ADMIN)); - } - - @Data - public static class NotificationsConfig { - @Field("emailNotifications") - private boolean emailNotifications = true; - @Field("criticalAlerts") - private boolean criticalAlerts = true; - @Field("weeklyReports") - private boolean weeklyReports = true; - @Field("maintenanceAlerts") - private boolean maintenanceAlerts = true; - @Field("slackWebhook") - private String slackWebhook = ""; - @Field("discordWebhook") - private String discordWebhook = ""; - } - - @Data - public static class PerformanceConfig { - @Field("cacheTtl") - private int cacheTtl = 300; - @Field("rateLimitRequests") - private int rateLimitRequests = 100; - @Field("rateLimitWindow") - private int rateLimitWindow = 60; - @Field("databaseConnectionPool") - private int databaseConnectionPool = 10; - @Field("enableCompression") - private boolean enableCompression = true; - @Field("enableCaching") - private boolean enableCaching = true; - } - - @Data - public static class FeaturesConfig { - @Field("analyticsEnabled") - private boolean analyticsEnabled = true; - @Field("auditLoggingEnabled") - private boolean auditLoggingEnabled = true; - @Field("apiAccessEnabled") - private boolean apiAccessEnabled = true; - @Field("bulkOperationsEnabled") - private boolean bulkOperationsEnabled = true; - @Field("advancedFiltering") - private boolean advancedFiltering = true; - @Field("realTimeUpdates") - private boolean realTimeUpdates = true; - } -} +package gg.modl.backend.admin.data; + +import gg.modl.backend.Constants; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = "system_config") +@GenerateMongoFields +public class SystemConfig { + @Id + private String id; + @Field("configId") + private String configId = "main_config"; + + @Field("general") + private GeneralConfig general = new GeneralConfig(); + @Field("logging") + private LoggingConfig logging = new LoggingConfig(); + @Field("security") + private SecurityConfig security = new SecurityConfig(); + @Field("notifications") + private NotificationsConfig notifications = new NotificationsConfig(); + @Field("performance") + private PerformanceConfig performance = new PerformanceConfig(); + @Field("features") + private FeaturesConfig features = new FeaturesConfig(); + + @Field("createdAt") + private Date createdAt = new Date(); + @Field("updatedAt") + private Date updatedAt = new Date(); + + @Data + public static class GeneralConfig { + @Field("systemName") + private String systemName = "modl Admin"; + @Field("adminEmail") + private String adminEmail = Constants.Email.ADMIN; + @Field("timezone") + private String timezone = "UTC"; + @Field("defaultLanguage") + private String defaultLanguage = "en"; + @Field("maintenanceMode") + private boolean maintenanceMode = false; + @Field("maintenanceMessage") + private String maintenanceMessage = "System under maintenance. Please check back later."; + } + + @Data + public static class LoggingConfig { + @Field("pm2LoggingEnabled") + private boolean pm2LoggingEnabled = true; + @Field("logRetentionDays") + private int logRetentionDays = 30; + @Field("maxLogSizePerDay") + private int maxLogSizePerDay = 1000000; + } + + @Data + public static class SecurityConfig { + @Field("sessionTimeout") + private int sessionTimeout = 60; + @Field("maxLoginAttempts") + private int maxLoginAttempts = 5; + @Field("lockoutDuration") + private int lockoutDuration = 15; + @Field("requireTwoFactor") + private boolean requireTwoFactor = false; + @Field("passwordMinLength") + private int passwordMinLength = 8; + @Field("passwordRequireSpecial") + private boolean passwordRequireSpecial = false; + @Field("ipWhitelist") + private List ipWhitelist = new ArrayList<>(); + @Field("corsOrigins") + private List corsOrigins = new ArrayList<>(List.of(Constants.Domain.HTTPS_ADMIN)); + } + + @Data + public static class NotificationsConfig { + @Field("emailNotifications") + private boolean emailNotifications = true; + @Field("criticalAlerts") + private boolean criticalAlerts = true; + @Field("weeklyReports") + private boolean weeklyReports = true; + @Field("maintenanceAlerts") + private boolean maintenanceAlerts = true; + @Field("slackWebhook") + private String slackWebhook = ""; + @Field("discordWebhook") + private String discordWebhook = ""; + } + + @Data + public static class PerformanceConfig { + @Field("cacheTtl") + private int cacheTtl = 300; + @Field("rateLimitRequests") + private int rateLimitRequests = 100; + @Field("rateLimitWindow") + private int rateLimitWindow = 60; + @Field("databaseConnectionPool") + private int databaseConnectionPool = 10; + @Field("enableCompression") + private boolean enableCompression = true; + @Field("enableCaching") + private boolean enableCaching = true; + } + + @Data + public static class FeaturesConfig { + @Field("analyticsEnabled") + private boolean analyticsEnabled = true; + @Field("auditLoggingEnabled") + private boolean auditLoggingEnabled = true; + @Field("apiAccessEnabled") + private boolean apiAccessEnabled = true; + @Field("bulkOperationsEnabled") + private boolean bulkOperationsEnabled = true; + @Field("advancedFiltering") + private boolean advancedFiltering = true; + @Field("realTimeUpdates") + private boolean realTimeUpdates = true; + } +} diff --git a/src/main/java/gg/modl/backend/admin/data/SystemLog.java b/src/main/java/gg/modl/backend/admin/data/SystemLog.java index 60fbbfe..8db9225 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemLog.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemLog.java @@ -1,50 +1,50 @@ -package gg.modl.backend.admin.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import lombok.Data; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Document(collection = "system_logs") -@GenerateMongoFields -public class SystemLog { - @Id - private String id; - @Field("level") - private String level; - @Field("message") - private String message; - @Field("source") - private String source; - @Field("category") - private String category; - @Field("serverId") - private String serverId; - @Field("metadata") - private Map metadata = new HashMap<>(); - @Field("resolved") - private boolean resolved; - @Field("resolvedBy") - private String resolvedBy; - @Field("resolvedAt") - private Date resolvedAt; - @Field("timestamp") - private Date timestamp; - - public SystemLog(String level, String message, String source) { - this(); - this.level = level; - this.message = message; - this.source = source; - } - - public SystemLog() { - this.timestamp = new Date(); - this.resolved = false; - } -} +package gg.modl.backend.admin.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.Data; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Document(collection = "system_logs") +@GenerateMongoFields +public class SystemLog { + @Id + private String id; + @Field("level") + private String level; + @Field("message") + private String message; + @Field("source") + private String source; + @Field("category") + private String category; + @Field("serverId") + private String serverId; + @Field("metadata") + private Map metadata = new HashMap<>(); + @Field("resolved") + private boolean resolved; + @Field("resolvedBy") + private String resolvedBy; + @Field("resolvedAt") + private Date resolvedAt; + @Field("timestamp") + private Date timestamp; + + public SystemLog(String level, String message, String source) { + this(); + this.level = level; + this.message = message; + this.source = source; + } + + public SystemLog() { + this.timestamp = new Date(); + this.resolved = false; + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java b/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java index f2fc6cd..5a96806 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java @@ -1,297 +1,297 @@ -package gg.modl.backend.admin.service; - -import gg.modl.backend.admin.data.SystemLog; -import gg.modl.backend.admin.dto.request.CreateSystemLogRequest; -import gg.modl.backend.admin.dto.request.ResolveLogRequest; -import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.MonitoringServerStats; -import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository.MonitoringLogStats; -import gg.modl.backend.server.data.ProvisioningStatus; -import gg.modl.backend.infrastructure.util.CsvUtil; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AdminMonitoringService { - private final SystemLogMongoRepository systemLogRepository; - private final ServerMongoRepository serverRepository; - private final GlobalMongoAdminRepository globalMongoAdminRepository; - - public Map getDashboard() { - Date oneDayAgo = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); - Date oneWeekAgo = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); - Date fiveMinutesAgo = Date.from(Instant.now().minus(5, ChronoUnit.MINUTES)); - - MonitoringServerStats serverStats = serverRepository.aggregateMonitoringServerStats(fiveMinutesAgo, oneWeekAgo); - MonitoringLogStats logStats = systemLogRepository.aggregateMonitoringLogStats(oneDayAgo); - - int healthScore = calculateHealthScore( - serverStats.total(), - serverStats.active(), - serverStats.failed(), - logStats.critical24h(), - logStats.error24h(), - logStats.unresolvedCritical(), - logStats.unresolvedError() - ); - String healthStatus = healthScore >= 95 ? "excellent" - : healthScore >= 85 ? "good" - : healthScore >= 70 ? "fair" - : "poor"; - - return Map.of( - "success", true, - "data", Map.of( - "servers", Map.of( - "total", serverStats.total(), - "active", serverStats.active(), - "pending", serverStats.pending(), - "failed", serverStats.failed(), - "recentRegistrations", serverStats.recentRegistrations(), - "concurrentServers", serverStats.concurrent(), - "concurrentPlayers", serverStats.concurrentPlayers() - ), - "logs", Map.of( - "last24h", Map.of( - "total", logStats.total24h(), - "critical", logStats.critical24h(), - "error", logStats.error24h(), - "warning", logStats.warning24h() - ), - "unresolved", Map.of( - "critical", logStats.unresolvedCritical(), - "error", logStats.unresolvedError() - ) - ), - "systemHealth", Map.of("score", healthScore, "status", healthStatus), - "trends", systemLogRepository.findLogTrends(oneWeekAgo), - "lastUpdated", new Date() - ) - ); - } - - private int calculateHealthScore(long total, long active, long failed, long critical, long errors, long unresolvedCritical, long unresolvedErrors) { - int score = 100; - if (total > 0) { - score -= (int) ((failed / (double) total) * 30); - } - score -= (int) Math.min(critical * 5, 25); - score -= (int) Math.min(errors, 20); - score -= (int) (unresolvedCritical * 10); - score -= (int) (unresolvedErrors * 3); - return Math.max(0, score); - } - - public Map getLogs( - int page, - int limit, - String level, - String source, - String serverId, - String category, - String resolved, - String search, - String startDate, - String endDate, - String sort, - String order - ) { - int pageNum = PaginationHelper.normalizePage(page); - int limitNum = PaginationHelper.normalizeLimit(limit, 100); - int skip = PaginationHelper.calculateSkip(page, limitNum); - Date start = DateRangeUtil.parseEpochMillis(startDate); - Date end = DateRangeUtil.parseEpochMillis(endDate); - - List logs = systemLogRepository.findLogs( - level, - source, - serverId, - category, - resolved, - search, - start, - end, - sort, - order, - skip, - limitNum - ); - long total = systemLogRepository.countLogs(level, source, serverId, category, resolved, search, start, end); - - Map filters = new LinkedHashMap<>(); - filters.put("level", level); - filters.put("source", source); - filters.put("serverId", serverId); - filters.put("category", category); - filters.put("resolved", resolved); - filters.put("search", search); - - return Map.of( - "success", true, - "data", Map.of( - "logs", logs, - "pagination", Map.of( - "page", pageNum, - "limit", limitNum, - "total", total, - "pages", PaginationHelper.calculateTotalPages(total, limitNum) - ), - "filters", filters - ) - ); - } - - public SystemLog createLog(CreateSystemLogRequest request) { - SystemLog logData = request.toSystemLog(); - logData.setTimestamp(new Date()); - return systemLogRepository.saveEntity(logData); - } - - public Map getSources() { - List sources = systemLogRepository.findDistinctSources(); - List categories = systemLogRepository.findDistinctCategories(); - - sources.removeIf(Objects::isNull); - categories.removeIf(Objects::isNull); - - return Map.of( - "success", true, - "data", Map.of( - "sources", sources, - "categories", categories - ) - ); - } - - public Optional resolveLog(String id, ResolveLogRequest request) { - return Optional.ofNullable(systemLogRepository.resolveById( - id, - request.resolvedBy() != null ? request.resolvedBy() : "admin", - new Date() - )); - } - - public Map getHealth() { - List> checks = new ArrayList<>(); - String overallStatus = "healthy"; - - try { - long start = System.currentTimeMillis(); - globalMongoAdminRepository.ping(); - long responseTime = System.currentTimeMillis() - start; - checks.add(Map.of( - "name", "Database Connectivity", - "status", "healthy", - "message", "MongoDB connection is responsive.", - "responseTime", responseTime - )); - } catch (Exception exception) { - checks.add(Map.of( - "name", "Database Connectivity", - "status", "critical", - "message", "Failed to ping MongoDB.", - "error", exception.getMessage() - )); - overallStatus = "critical"; - } - - long criticalCount = systemLogRepository.countUnresolvedByLevelSince( - "critical", - Date.from(Instant.now().minus(1, ChronoUnit.DAYS)) - ); - String logStatus = criticalCount > 5 ? "critical" : criticalCount > 0 ? "degraded" : "healthy"; - checks.add(Map.of( - "name", "Critical System Logs", - "status", logStatus, - "message", criticalCount + " unresolved critical log(s) in the last 24 hours.", - "count", criticalCount - )); - if ("critical".equals(logStatus)) { - overallStatus = "critical"; - } else if ("degraded".equals(logStatus) && !"critical".equals(overallStatus)) { - overallStatus = "degraded"; - } - - long failedCount = serverRepository.countByProvisioningStatus(ProvisioningStatus.FAILED); - String serverStatus = failedCount > 0 ? "degraded" : "healthy"; - checks.add(Map.of( - "name", "Server Provisioning", - "status", serverStatus, - "message", failedCount + " server(s) failed to provision.", - "count", failedCount - )); - if ("degraded".equals(serverStatus) && !"critical".equals(overallStatus)) { - overallStatus = "degraded"; - } - - return Map.of( - "success", true, - "data", Map.of( - "status", overallStatus, - "checks", checks, - "timestamp", new Date() - ) - ); - } - - public long deleteLogs(List logIds) { - return systemLogRepository.deleteByIds(logIds); - } - - public String exportLogs( - String level, - String source, - String category, - String resolved, - String search, - String startDate, - String endDate - ) { - List logs = systemLogRepository.findLogsForExport( - DateRangeUtil.normalizeAllFilter(level), - DateRangeUtil.normalizeAllFilter(source), - null, - DateRangeUtil.normalizeAllFilter(category), - DateRangeUtil.normalizeAllFilter(resolved), - search, - DateRangeUtil.parseEpochMillis(startDate), - DateRangeUtil.parseEpochMillis(endDate), - 10000 - ); - - StringBuilder csv = new StringBuilder(CsvUtil.row("Timestamp", "Level", "Source", "Category", "Message", "Resolved", "Resolved By")); - for (SystemLog logEntry : logs) { - csv.append(CsvUtil.row( - logEntry.getTimestamp(), - logEntry.getLevel(), - logEntry.getSource(), - logEntry.getCategory(), - logEntry.getMessage(), - logEntry.isResolved() ? "Yes" : "No", - logEntry.getResolvedBy())); - } - return csv.toString(); - } - - public long clearAllLogs() { - long deletedCount = systemLogRepository.deleteAllLogs(); - log.info("All system logs cleared by admin"); - return deletedCount; - } -} +package gg.modl.backend.admin.service; + +import gg.modl.backend.admin.data.SystemLog; +import gg.modl.backend.admin.dto.request.CreateSystemLogRequest; +import gg.modl.backend.admin.dto.request.ResolveLogRequest; +import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository.MonitoringServerStats; +import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository.MonitoringLogStats; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.infrastructure.util.CsvUtil; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AdminMonitoringService { + private final SystemLogMongoRepository systemLogRepository; + private final ServerMongoRepository serverRepository; + private final GlobalMongoAdminRepository globalMongoAdminRepository; + + public Map getDashboard() { + Date oneDayAgo = Date.from(Instant.now().minus(1, ChronoUnit.DAYS)); + Date oneWeekAgo = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); + Date fiveMinutesAgo = Date.from(Instant.now().minus(5, ChronoUnit.MINUTES)); + + MonitoringServerStats serverStats = serverRepository.aggregateMonitoringServerStats(fiveMinutesAgo, oneWeekAgo); + MonitoringLogStats logStats = systemLogRepository.aggregateMonitoringLogStats(oneDayAgo); + + int healthScore = calculateHealthScore( + serverStats.total(), + serverStats.active(), + serverStats.failed(), + logStats.critical24h(), + logStats.error24h(), + logStats.unresolvedCritical(), + logStats.unresolvedError() + ); + String healthStatus = healthScore >= 95 ? "excellent" + : healthScore >= 85 ? "good" + : healthScore >= 70 ? "fair" + : "poor"; + + return Map.of( + "success", true, + "data", Map.of( + "servers", Map.of( + "total", serverStats.total(), + "active", serverStats.active(), + "pending", serverStats.pending(), + "failed", serverStats.failed(), + "recentRegistrations", serverStats.recentRegistrations(), + "concurrentServers", serverStats.concurrent(), + "concurrentPlayers", serverStats.concurrentPlayers() + ), + "logs", Map.of( + "last24h", Map.of( + "total", logStats.total24h(), + "critical", logStats.critical24h(), + "error", logStats.error24h(), + "warning", logStats.warning24h() + ), + "unresolved", Map.of( + "critical", logStats.unresolvedCritical(), + "error", logStats.unresolvedError() + ) + ), + "systemHealth", Map.of("score", healthScore, "status", healthStatus), + "trends", systemLogRepository.findLogTrends(oneWeekAgo), + "lastUpdated", new Date() + ) + ); + } + + private int calculateHealthScore(long total, long active, long failed, long critical, long errors, long unresolvedCritical, long unresolvedErrors) { + int score = 100; + if (total > 0) { + score -= (int) ((failed / (double) total) * 30); + } + score -= (int) Math.min(critical * 5, 25); + score -= (int) Math.min(errors, 20); + score -= (int) (unresolvedCritical * 10); + score -= (int) (unresolvedErrors * 3); + return Math.max(0, score); + } + + public Map getLogs( + int page, + int limit, + String level, + String source, + String serverId, + String category, + String resolved, + String search, + String startDate, + String endDate, + String sort, + String order + ) { + int pageNum = PaginationHelper.normalizePage(page); + int limitNum = PaginationHelper.normalizeLimit(limit, 100); + int skip = PaginationHelper.calculateSkip(page, limitNum); + Date start = DateRangeUtil.parseEpochMillis(startDate); + Date end = DateRangeUtil.parseEpochMillis(endDate); + + List logs = systemLogRepository.findLogs( + level, + source, + serverId, + category, + resolved, + search, + start, + end, + sort, + order, + skip, + limitNum + ); + long total = systemLogRepository.countLogs(level, source, serverId, category, resolved, search, start, end); + + Map filters = new LinkedHashMap<>(); + filters.put("level", level); + filters.put("source", source); + filters.put("serverId", serverId); + filters.put("category", category); + filters.put("resolved", resolved); + filters.put("search", search); + + return Map.of( + "success", true, + "data", Map.of( + "logs", logs, + "pagination", Map.of( + "page", pageNum, + "limit", limitNum, + "total", total, + "pages", PaginationHelper.calculateTotalPages(total, limitNum) + ), + "filters", filters + ) + ); + } + + public SystemLog createLog(CreateSystemLogRequest request) { + SystemLog logData = request.toSystemLog(); + logData.setTimestamp(new Date()); + return systemLogRepository.saveEntity(logData); + } + + public Map getSources() { + List sources = systemLogRepository.findDistinctSources(); + List categories = systemLogRepository.findDistinctCategories(); + + sources.removeIf(Objects::isNull); + categories.removeIf(Objects::isNull); + + return Map.of( + "success", true, + "data", Map.of( + "sources", sources, + "categories", categories + ) + ); + } + + public Optional resolveLog(String id, ResolveLogRequest request) { + return Optional.ofNullable(systemLogRepository.resolveById( + id, + request.resolvedBy() != null ? request.resolvedBy() : "admin", + new Date() + )); + } + + public Map getHealth() { + List> checks = new ArrayList<>(); + String overallStatus = "healthy"; + + try { + long start = System.currentTimeMillis(); + globalMongoAdminRepository.ping(); + long responseTime = System.currentTimeMillis() - start; + checks.add(Map.of( + "name", "Database Connectivity", + "status", "healthy", + "message", "MongoDB connection is responsive.", + "responseTime", responseTime + )); + } catch (Exception exception) { + checks.add(Map.of( + "name", "Database Connectivity", + "status", "critical", + "message", "Failed to ping MongoDB.", + "error", exception.getMessage() + )); + overallStatus = "critical"; + } + + long criticalCount = systemLogRepository.countUnresolvedByLevelSince( + "critical", + Date.from(Instant.now().minus(1, ChronoUnit.DAYS)) + ); + String logStatus = criticalCount > 5 ? "critical" : criticalCount > 0 ? "degraded" : "healthy"; + checks.add(Map.of( + "name", "Critical System Logs", + "status", logStatus, + "message", criticalCount + " unresolved critical log(s) in the last 24 hours.", + "count", criticalCount + )); + if ("critical".equals(logStatus)) { + overallStatus = "critical"; + } else if ("degraded".equals(logStatus) && !"critical".equals(overallStatus)) { + overallStatus = "degraded"; + } + + long failedCount = serverRepository.countByProvisioningStatus(ProvisioningStatus.FAILED); + String serverStatus = failedCount > 0 ? "degraded" : "healthy"; + checks.add(Map.of( + "name", "Server Provisioning", + "status", serverStatus, + "message", failedCount + " server(s) failed to provision.", + "count", failedCount + )); + if ("degraded".equals(serverStatus) && !"critical".equals(overallStatus)) { + overallStatus = "degraded"; + } + + return Map.of( + "success", true, + "data", Map.of( + "status", overallStatus, + "checks", checks, + "timestamp", new Date() + ) + ); + } + + public long deleteLogs(List logIds) { + return systemLogRepository.deleteByIds(logIds); + } + + public String exportLogs( + String level, + String source, + String category, + String resolved, + String search, + String startDate, + String endDate + ) { + List logs = systemLogRepository.findLogsForExport( + DateRangeUtil.normalizeAllFilter(level), + DateRangeUtil.normalizeAllFilter(source), + null, + DateRangeUtil.normalizeAllFilter(category), + DateRangeUtil.normalizeAllFilter(resolved), + search, + DateRangeUtil.parseEpochMillis(startDate), + DateRangeUtil.parseEpochMillis(endDate), + 10000 + ); + + StringBuilder csv = new StringBuilder(CsvUtil.row("Timestamp", "Level", "Source", "Category", "Message", "Resolved", "Resolved By")); + for (SystemLog logEntry : logs) { + csv.append(CsvUtil.row( + logEntry.getTimestamp(), + logEntry.getLevel(), + logEntry.getSource(), + logEntry.getCategory(), + logEntry.getMessage(), + logEntry.isResolved() ? "Yes" : "No", + logEntry.getResolvedBy())); + } + return csv.toString(); + } + + public long clearAllLogs() { + long deletedCount = systemLogRepository.deleteAllLogs(); + log.info("All system logs cleared by admin"); + return deletedCount; + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java b/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java index 219d7bd..159df9d 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java @@ -1,75 +1,75 @@ -package gg.modl.backend.admin.service; - -import gg.modl.backend.admin.data.SecurityEvent; -import gg.modl.backend.database.mongo.repository.SecurityEventMongoRepository; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class AdminSecurityService { - private final SecurityEventMongoRepository securityEventRepository; - - public Map getSecurityEvents( - int page, - int limit, - String type, - String severity, - String source, - String search, - String startDate, - String endDate - ) { - int pageNum = PaginationHelper.normalizePage(page); - int limitNum = PaginationHelper.normalizeLimit(limit, 100); - int skip = PaginationHelper.calculateSkip(page, limitNum); - Date start = DateRangeUtil.parseEpochMillis(startDate); - Date end = DateRangeUtil.parseEpochMillis(endDate); - - List events = securityEventRepository.findSecurityEvents(type, severity, source, search, start, end, skip, limitNum); - long total = securityEventRepository.countSecurityEvents(type, severity, source, search, start, end); - - return Map.of( - "success", true, - "data", Map.of( - "events", events, - "pagination", Map.of( - "page", pageNum, - "limit", limitNum, - "total", total, - "pages", PaginationHelper.calculateTotalPages(total, limitNum) - ) - ) - ); - } - - public Map getSecuritySummary() { - Date last24h = Date.from(Instant.now().minus(24, ChronoUnit.HOURS)); - Date last7d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); - - long criticalEvents24h = securityEventRepository.countBySeveritySince("critical", last24h); - long highEvents24h = securityEventRepository.countBySeveritySince("high", last24h); - long mediumEvents24h = securityEventRepository.countBySeveritySince("medium", last24h); - long totalEvents7d = securityEventRepository.countSince(last7d); - - return Map.of( - "success", true, - "data", Map.of( - "last24Hours", Map.of( - "critical", criticalEvents24h, - "high", highEvents24h, - "medium", mediumEvents24h - ), - "last7Days", Map.of("total", totalEvents7d), - "timestamp", new Date() - ) - ); - } -} +package gg.modl.backend.admin.service; + +import gg.modl.backend.admin.data.SecurityEvent; +import gg.modl.backend.database.mongo.repository.SecurityEventMongoRepository; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminSecurityService { + private final SecurityEventMongoRepository securityEventRepository; + + public Map getSecurityEvents( + int page, + int limit, + String type, + String severity, + String source, + String search, + String startDate, + String endDate + ) { + int pageNum = PaginationHelper.normalizePage(page); + int limitNum = PaginationHelper.normalizeLimit(limit, 100); + int skip = PaginationHelper.calculateSkip(page, limitNum); + Date start = DateRangeUtil.parseEpochMillis(startDate); + Date end = DateRangeUtil.parseEpochMillis(endDate); + + List events = securityEventRepository.findSecurityEvents(type, severity, source, search, start, end, skip, limitNum); + long total = securityEventRepository.countSecurityEvents(type, severity, source, search, start, end); + + return Map.of( + "success", true, + "data", Map.of( + "events", events, + "pagination", Map.of( + "page", pageNum, + "limit", limitNum, + "total", total, + "pages", PaginationHelper.calculateTotalPages(total, limitNum) + ) + ) + ); + } + + public Map getSecuritySummary() { + Date last24h = Date.from(Instant.now().minus(24, ChronoUnit.HOURS)); + Date last7d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); + + long criticalEvents24h = securityEventRepository.countBySeveritySince("critical", last24h); + long highEvents24h = securityEventRepository.countBySeveritySince("high", last24h); + long mediumEvents24h = securityEventRepository.countBySeveritySince("medium", last24h); + long totalEvents7d = securityEventRepository.countSince(last7d); + + return Map.of( + "success", true, + "data", Map.of( + "last24Hours", Map.of( + "critical", criticalEvents24h, + "high", highEvents24h, + "medium", mediumEvents24h + ), + "last7Days", Map.of("total", totalEvents7d), + "timestamp", new Date() + ) + ); + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminServerService.java b/src/main/java/gg/modl/backend/admin/service/AdminServerService.java index 6dc0388..63dffaa 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminServerService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminServerService.java @@ -1,268 +1,268 @@ -package gg.modl.backend.admin.service; - -import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.util.CsvUtil; -import gg.modl.backend.server.ServerService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.service.ServerProvisioningService; -import gg.modl.backend.staff.service.StaffService; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AdminServerService { - private final ServerMongoRepository serverRepository; - private final ServerDatabaseMongoRepository serverDatabaseRepository; - private final ServerProvisioningService provisioningService; - private final ServerService serverService; - private final StaffService staffService; - private static final long USAGE_STATS_TTL_MILLIS = 10 * 60 * 1000L; - private static final int MAX_USAGE_BATCH_SIZE = 50; - - public long countServers(String search, String plan, String status) { - return serverRepository.countAdminServers(search, plan, status); - } - - @Async - public void refreshUsageStatsForActiveServers(int maxServers) { - int boundedLimit = Math.max(1, Math.min(maxServers, 500)); - Date now = new Date(); - Date staleCutoff = new Date(now.getTime() - USAGE_STATS_TTL_MILLIS); - - List servers = serverRepository.findUsageRefreshCandidates(staleCutoff, boundedLimit); - for (Server server : servers) { - getOrComputeUsageStats(server, now, false); - } - } - - private ComputedUsage getOrComputeUsageStats(Server server, Date now, boolean forceRefresh) { - long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; - long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; - - if (!forceRefresh && isUsageStatsCacheFresh(server, now)) { - return new ComputedUsage(cachedUsers, cachedTickets, server.getLastStatsUpdatedAt(), true); - } - - if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { - persistUsageStats(server.getId(), cachedUsers, cachedTickets, now); - return new ComputedUsage(cachedUsers, cachedTickets, now, false); - } - - Optional usageCounts = serverDatabaseRepository.readUsageCounts(server); - if (usageCounts.isPresent()) { - persistUsageStats(server.getId(), usageCounts.get().players(), usageCounts.get().tickets(), now); - return new ComputedUsage(usageCounts.get().players(), usageCounts.get().tickets(), now, false); - } - - log.warn("Failed to refresh usage stats for server {}", server.getServerName()); - Date updatedAt = server.getLastStatsUpdatedAt() != null ? server.getLastStatsUpdatedAt() : now; - return new ComputedUsage(cachedUsers, cachedTickets, updatedAt, true); - } - - private boolean isUsageStatsCacheFresh(Server server, Date now) { - if (server.getLastStatsUpdatedAt() == null) { - return false; - } - - long ageMillis = now.getTime() - server.getLastStatsUpdatedAt().getTime(); - return ageMillis >= 0 && ageMillis <= USAGE_STATS_TTL_MILLIS; - } - - private void persistUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { - serverRepository.updateUsageStats(serverId, userCount, ticketCount, updatedAt); - } - - public Map getUsageStatsForServerIds(List serverIds, boolean forceRefresh) { - if (serverIds == null || serverIds.isEmpty()) { - return Map.of(); - } - - List filteredIds = serverIds.stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(id -> !id.isEmpty()) - .distinct() - .limit(MAX_USAGE_BATCH_SIZE) - .toList(); - - if (filteredIds.isEmpty()) { - return Map.of(); - } - - Date now = new Date(); - List servers = serverRepository.findUsageTargetsByIds(filteredIds); - Map usageByServerId = new HashMap<>(); - - for (Server server : servers) { - ComputedUsage usage = getOrComputeUsageStats(server, now, forceRefresh); - usageByServerId.put(server.getId(), new UsageSummary( - usage.userCount(), - usage.ticketCount(), - usage.updatedAt(), - usage.fromCache() - )); - } - - return usageByServerId; - } - - public Server createServer(String serverName, String customDomain, String adminEmail, String plan) { - ServerPlan serverPlan = plan != null ? ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)) : ServerPlan.FREE; - return serverService.createServer(serverName, customDomain, adminEmail, null, serverPlan); - } - - public void changeAdminEmail(Server server, String newAdminEmail) { - String normalizedEmail = EmailAddressUtil.normalizeIfValid(newAdminEmail); - if (normalizedEmail == null) { - throw new ValidationException("A valid admin email is required"); - } - if (serverService.isAdminEmailInUse(normalizedEmail, server.getId())) { - throw new ValidationException("Admin email is already in use by another server"); - } - String previousAdminEmail = server.getAdminEmail(); - serverService.changeAdminEmail(server, normalizedEmail); - if (previousAdminEmail != null && !previousAdminEmail.equalsIgnoreCase(normalizedEmail) - && server.getDatabaseName() != null && !server.getDatabaseName().isBlank()) { - staffService.offboardPreviousAdminEmail(server, previousAdminEmail); - } - } - - public String exportServersCsv(String plan, String status) { - List servers = findServers(null, plan, status, "createdAt", "desc", 0, 10000); - StringBuilder csv = new StringBuilder(); - csv.append(CsvUtil.row("id", "serverName", "customDomain", "adminEmail", "plan", "provisioningStatus", "emailVerified", "createdAt")); - for (Server s : servers) { - csv.append(CsvUtil.row( - s.getId(), s.getServerName(), s.getCustomDomain(), s.getAdminEmail(), - s.getPlan(), s.getProvisioningStatus(), s.getEmailVerified(), s.getCreatedAt())); - } - return csv.toString(); - } - - public List findServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { - return serverRepository.findAdminServers(search, plan, status, sortField, sortOrder, skip, limit); - } - - public Optional findById(String id) { - return serverRepository.findById(id); - } - - public Server save(Server server) { - Server saved = serverRepository.saveEntity(server); - serverService.evictAllServerCaches(); - return saved; - } - - public Server updateById(String id, Map updateData) { - return serverRepository.updateAllowedFields(id, updateData).orElse(null); - } - - public boolean deleteById(String id) { - return serverRepository.deleteByServerId(id); - } - - public long bulkDelete(List serverIds) { - return serverRepository.deleteByServerIds(serverIds); - } - - public long bulkSuspend(List serverIds) { - return serverRepository.bulkSuspend(serverIds, new Date()); - } - - public long bulkActivate(List serverIds) { - long modified = serverRepository.bulkActivate(serverIds, new Date()); - - List servers = serverRepository.findProvisioningCandidatesByIds(serverIds); - for (Server server : servers) { - if (server.getDatabaseName() == null) { - continue; - } - try { - provisioningService.provision(server); - serverRepository.markProvisioningCompleted(server.getId()); - } catch (Exception e) { - log.warn("Failed to provision server {}", server.getId(), e); - serverRepository.markProvisioningFailed(server.getId(), "Admin reprovision failed."); - } - } - - serverService.evictAllServerCaches(); - - return modified; - } - - public long bulkUpdatePlan(List serverIds, String plan) { - ServerPlan parsedPlan = ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)); - return serverRepository.bulkUpdatePlan(serverIds, parsedPlan, new Date()); - } - - public Map getServerStats(Server server) { - Map stats = new HashMap<>(); - Date now = new Date(); - - if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { - long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; - long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; - stats.put("totalPlayers", cachedUsers); - stats.put("totalTickets", cachedTickets); - stats.put("totalLogs", 0); - stats.put("lastActivity", server.getUpdatedAt()); - stats.put("databaseSize", 0); - return stats; - } - - Optional databaseStats = serverDatabaseRepository.readStats(server); - if (databaseStats.isPresent()) { - ServerDatabaseMongoRepository.ServerDatabaseStats loadedStats = databaseStats.get(); - persistUsageStats(server.getId(), loadedStats.players(), loadedStats.tickets(), now); - - stats.put("totalPlayers", loadedStats.players()); - stats.put("totalTickets", loadedStats.tickets()); - stats.put("totalLogs", loadedStats.logs()); - stats.put("lastActivity", server.getLastActivityAt() != null ? server.getLastActivityAt() : server.getUpdatedAt()); - stats.put("databaseSize", loadedStats.storageSize()); - return stats; - } - - log.warn("Failed to get stats for server {}", server.getServerName()); - long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; - long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; - stats.put("totalPlayers", cachedUsers); - stats.put("totalTickets", cachedTickets); - stats.put("totalLogs", 0); - stats.put("lastActivity", server.getUpdatedAt()); - stats.put("databaseSize", 0); - return stats; - } - - public void resetServerDatabase(Server server) { - if (server.getDatabaseName() != null) { - if (serverDatabaseRepository.dropDatabase(server)) { - log.info("Dropped database {} for server {}", server.getDatabaseName(), server.getServerName()); - } else { - log.warn("Failed to drop database {}", server.getDatabaseName()); - } - } - - serverRepository.resetAfterDatabaseDrop(server.getId(), new Date()); - } - - private record ComputedUsage(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} - - public record UsageSummary(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} -} +package gg.modl.backend.admin.service; + +import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.util.CsvUtil; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.service.ServerProvisioningService; +import gg.modl.backend.staff.service.StaffService; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AdminServerService { + private final ServerMongoRepository serverRepository; + private final ServerDatabaseMongoRepository serverDatabaseRepository; + private final ServerProvisioningService provisioningService; + private final ServerService serverService; + private final StaffService staffService; + private static final long USAGE_STATS_TTL_MILLIS = 10 * 60 * 1000L; + private static final int MAX_USAGE_BATCH_SIZE = 50; + + public long countServers(String search, String plan, String status) { + return serverRepository.countAdminServers(search, plan, status); + } + + @Async + public void refreshUsageStatsForActiveServers(int maxServers) { + int boundedLimit = Math.max(1, Math.min(maxServers, 500)); + Date now = new Date(); + Date staleCutoff = new Date(now.getTime() - USAGE_STATS_TTL_MILLIS); + + List servers = serverRepository.findUsageRefreshCandidates(staleCutoff, boundedLimit); + for (Server server : servers) { + getOrComputeUsageStats(server, now, false); + } + } + + private ComputedUsage getOrComputeUsageStats(Server server, Date now, boolean forceRefresh) { + long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; + long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; + + if (!forceRefresh && isUsageStatsCacheFresh(server, now)) { + return new ComputedUsage(cachedUsers, cachedTickets, server.getLastStatsUpdatedAt(), true); + } + + if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { + persistUsageStats(server.getId(), cachedUsers, cachedTickets, now); + return new ComputedUsage(cachedUsers, cachedTickets, now, false); + } + + Optional usageCounts = serverDatabaseRepository.readUsageCounts(server); + if (usageCounts.isPresent()) { + persistUsageStats(server.getId(), usageCounts.get().players(), usageCounts.get().tickets(), now); + return new ComputedUsage(usageCounts.get().players(), usageCounts.get().tickets(), now, false); + } + + log.warn("Failed to refresh usage stats for server {}", server.getServerName()); + Date updatedAt = server.getLastStatsUpdatedAt() != null ? server.getLastStatsUpdatedAt() : now; + return new ComputedUsage(cachedUsers, cachedTickets, updatedAt, true); + } + + private boolean isUsageStatsCacheFresh(Server server, Date now) { + if (server.getLastStatsUpdatedAt() == null) { + return false; + } + + long ageMillis = now.getTime() - server.getLastStatsUpdatedAt().getTime(); + return ageMillis >= 0 && ageMillis <= USAGE_STATS_TTL_MILLIS; + } + + private void persistUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { + serverRepository.updateUsageStats(serverId, userCount, ticketCount, updatedAt); + } + + public Map getUsageStatsForServerIds(List serverIds, boolean forceRefresh) { + if (serverIds == null || serverIds.isEmpty()) { + return Map.of(); + } + + List filteredIds = serverIds.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(id -> !id.isEmpty()) + .distinct() + .limit(MAX_USAGE_BATCH_SIZE) + .toList(); + + if (filteredIds.isEmpty()) { + return Map.of(); + } + + Date now = new Date(); + List servers = serverRepository.findUsageTargetsByIds(filteredIds); + Map usageByServerId = new HashMap<>(); + + for (Server server : servers) { + ComputedUsage usage = getOrComputeUsageStats(server, now, forceRefresh); + usageByServerId.put(server.getId(), new UsageSummary( + usage.userCount(), + usage.ticketCount(), + usage.updatedAt(), + usage.fromCache() + )); + } + + return usageByServerId; + } + + public Server createServer(String serverName, String customDomain, String adminEmail, String plan) { + ServerPlan serverPlan = plan != null ? ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)) : ServerPlan.FREE; + return serverService.createServer(serverName, customDomain, adminEmail, null, serverPlan); + } + + public void changeAdminEmail(Server server, String newAdminEmail) { + String normalizedEmail = EmailAddressUtil.normalizeIfValid(newAdminEmail); + if (normalizedEmail == null) { + throw new ValidationException("A valid admin email is required"); + } + if (serverService.isAdminEmailInUse(normalizedEmail, server.getId())) { + throw new ValidationException("Admin email is already in use by another server"); + } + String previousAdminEmail = server.getAdminEmail(); + serverService.changeAdminEmail(server, normalizedEmail); + if (previousAdminEmail != null && !previousAdminEmail.equalsIgnoreCase(normalizedEmail) + && server.getDatabaseName() != null && !server.getDatabaseName().isBlank()) { + staffService.offboardPreviousAdminEmail(server, previousAdminEmail); + } + } + + public String exportServersCsv(String plan, String status) { + List servers = findServers(null, plan, status, "createdAt", "desc", 0, 10000); + StringBuilder csv = new StringBuilder(); + csv.append(CsvUtil.row("id", "serverName", "customDomain", "adminEmail", "plan", "provisioningStatus", "emailVerified", "createdAt")); + for (Server s : servers) { + csv.append(CsvUtil.row( + s.getId(), s.getServerName(), s.getCustomDomain(), s.getAdminEmail(), + s.getPlan(), s.getProvisioningStatus(), s.getEmailVerified(), s.getCreatedAt())); + } + return csv.toString(); + } + + public List findServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { + return serverRepository.findAdminServers(search, plan, status, sortField, sortOrder, skip, limit); + } + + public Optional findById(String id) { + return serverRepository.findById(id); + } + + public Server save(Server server) { + Server saved = serverRepository.saveEntity(server); + serverService.evictAllServerCaches(); + return saved; + } + + public Server updateById(String id, Map updateData) { + return serverRepository.updateAllowedFields(id, updateData).orElse(null); + } + + public boolean deleteById(String id) { + return serverRepository.deleteByServerId(id); + } + + public long bulkDelete(List serverIds) { + return serverRepository.deleteByServerIds(serverIds); + } + + public long bulkSuspend(List serverIds) { + return serverRepository.bulkSuspend(serverIds, new Date()); + } + + public long bulkActivate(List serverIds) { + long modified = serverRepository.bulkActivate(serverIds, new Date()); + + List servers = serverRepository.findProvisioningCandidatesByIds(serverIds); + for (Server server : servers) { + if (server.getDatabaseName() == null) { + continue; + } + try { + provisioningService.provision(server); + serverRepository.markProvisioningCompleted(server.getId()); + } catch (Exception e) { + log.warn("Failed to provision server {}", server.getId(), e); + serverRepository.markProvisioningFailed(server.getId(), "Admin reprovision failed."); + } + } + + serverService.evictAllServerCaches(); + + return modified; + } + + public long bulkUpdatePlan(List serverIds, String plan) { + ServerPlan parsedPlan = ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)); + return serverRepository.bulkUpdatePlan(serverIds, parsedPlan, new Date()); + } + + public Map getServerStats(Server server) { + Map stats = new HashMap<>(); + Date now = new Date(); + + if (server.getDatabaseName() == null || server.getDatabaseName().isBlank()) { + long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; + long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; + stats.put("totalPlayers", cachedUsers); + stats.put("totalTickets", cachedTickets); + stats.put("totalLogs", 0); + stats.put("lastActivity", server.getUpdatedAt()); + stats.put("databaseSize", 0); + return stats; + } + + Optional databaseStats = serverDatabaseRepository.readStats(server); + if (databaseStats.isPresent()) { + ServerDatabaseMongoRepository.ServerDatabaseStats loadedStats = databaseStats.get(); + persistUsageStats(server.getId(), loadedStats.players(), loadedStats.tickets(), now); + + stats.put("totalPlayers", loadedStats.players()); + stats.put("totalTickets", loadedStats.tickets()); + stats.put("totalLogs", loadedStats.logs()); + stats.put("lastActivity", server.getLastActivityAt() != null ? server.getLastActivityAt() : server.getUpdatedAt()); + stats.put("databaseSize", loadedStats.storageSize()); + return stats; + } + + log.warn("Failed to get stats for server {}", server.getServerName()); + long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; + long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; + stats.put("totalPlayers", cachedUsers); + stats.put("totalTickets", cachedTickets); + stats.put("totalLogs", 0); + stats.put("lastActivity", server.getUpdatedAt()); + stats.put("databaseSize", 0); + return stats; + } + + public void resetServerDatabase(Server server) { + if (server.getDatabaseName() != null) { + if (serverDatabaseRepository.dropDatabase(server)) { + log.info("Dropped database {} for server {}", server.getDatabaseName(), server.getServerName()); + } else { + log.warn("Failed to drop database {}", server.getDatabaseName()); + } + } + + serverRepository.resetAfterDatabaseDrop(server.getId(), new Date()); + } + + private record ComputedUsage(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} + + public record UsageSummary(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} +} diff --git a/src/main/java/gg/modl/backend/ai/LLMConfiguration.java b/src/main/java/gg/modl/backend/ai/LLMConfiguration.java index 73181e6..b26b4a5 100644 --- a/src/main/java/gg/modl/backend/ai/LLMConfiguration.java +++ b/src/main/java/gg/modl/backend/ai/LLMConfiguration.java @@ -1,20 +1,20 @@ -package gg.modl.backend.ai; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.validation.annotation.Validated; - -@Component -@ConfigurationProperties(prefix = "modl.llm") -@Validated -@Getter -@Setter -public class LLMConfiguration { - private String geminiModelId; - private String geminiApiKey; - private float geminiTemperature; - private int geminiMaxOutputTokens; - private float geminiTopP; -} +package gg.modl.backend.ai; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@ConfigurationProperties(prefix = "modl.llm") +@Validated +@Getter +@Setter +public class LLMConfiguration { + private String geminiModelId; + private String geminiApiKey; + private float geminiTemperature; + private int geminiMaxOutputTokens; + private float geminiTopP; +} diff --git a/src/main/java/gg/modl/backend/ai/LLMService.java b/src/main/java/gg/modl/backend/ai/LLMService.java index 1eb6230..8087e62 100644 --- a/src/main/java/gg/modl/backend/ai/LLMService.java +++ b/src/main/java/gg/modl/backend/ai/LLMService.java @@ -1,40 +1,40 @@ -package gg.modl.backend.ai; - -import gg.modl.backend.ai.external.GeminiLLMProvider; -import gg.modl.backend.ai.external.LLMProvider; -import jakarta.annotation.PostConstruct; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class LLMService { - private final LLMConfiguration config; - private LLMProvider llmProvider; - - @PostConstruct - public void init() { - if (config.getGeminiApiKey() != null && !config.getGeminiApiKey().isBlank()) { - this.llmProvider = new GeminiLLMProvider(config); - log.info("LLM service initialized with Gemini provider"); - } else { - log.warn("LLM service not initialized: Gemini API key not configured"); - } - } - - @NotNull - public String generate(@NotNull String systemInstruction, @NotNull String userContent) { - if (!isAvailable()) { - throw new IllegalStateException("LLM provider not initialized. Check API key configuration."); - } - - return llmProvider.generate(systemInstruction, userContent); - } - - public boolean isAvailable() { - return llmProvider != null && llmProvider.isConnected(); - } -} +package gg.modl.backend.ai; + +import gg.modl.backend.ai.external.GeminiLLMProvider; +import gg.modl.backend.ai.external.LLMProvider; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LLMService { + private final LLMConfiguration config; + private LLMProvider llmProvider; + + @PostConstruct + public void init() { + if (config.getGeminiApiKey() != null && !config.getGeminiApiKey().isBlank()) { + this.llmProvider = new GeminiLLMProvider(config); + log.info("LLM service initialized with Gemini provider"); + } else { + log.warn("LLM service not initialized: Gemini API key not configured"); + } + } + + @NotNull + public String generate(@NotNull String systemInstruction, @NotNull String userContent) { + if (!isAvailable()) { + throw new IllegalStateException("LLM provider not initialized. Check API key configuration."); + } + + return llmProvider.generate(systemInstruction, userContent); + } + + public boolean isAvailable() { + return llmProvider != null && llmProvider.isConnected(); + } +} diff --git a/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java b/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java index cf7fa9b..9934353 100644 --- a/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java +++ b/src/main/java/gg/modl/backend/ai/data/AIAnalysisResult.java @@ -1,33 +1,33 @@ -package gg.modl.backend.ai.data; - -import java.util.Date; -import lombok.Data; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@Data -public class AIAnalysisResult { - @NotNull - private final String analysis; - @Nullable - private final SuggestedAction suggestedAction; - @NotNull - private final Date createdAt; - @NotNull - private final String rawResponse; - @Nullable - private Double confidence; - private boolean wasAppliedAutomatically; - private boolean dismissed = false; - - public boolean hasViolation() { - return suggestedAction != null; - } - - @Data - public static class SuggestedAction { - private final int punishmentTypeId; - @NotNull - private final String severity; - } -} +package gg.modl.backend.ai.data; + +import java.util.Date; +import lombok.Data; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Data +public class AIAnalysisResult { + @NotNull + private final String analysis; + @Nullable + private final SuggestedAction suggestedAction; + @NotNull + private final Date createdAt; + @NotNull + private final String rawResponse; + @Nullable + private Double confidence; + private boolean wasAppliedAutomatically; + private boolean dismissed = false; + + public boolean hasViolation() { + return suggestedAction != null; + } + + @Data + public static class SuggestedAction { + private final int punishmentTypeId; + @NotNull + private final String severity; + } +} diff --git a/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java b/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java index e884990..16878d4 100644 --- a/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java +++ b/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java @@ -1,64 +1,64 @@ -package gg.modl.backend.ai.external; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.genai.Client; -import com.google.genai.types.Content; -import com.google.genai.types.GenerateContentConfig; -import com.google.genai.types.GenerateContentResponse; -import com.google.genai.types.Part; -import com.google.genai.types.Schema; -import gg.modl.backend.ai.LLMConfiguration; -import gg.modl.backend.ai.data.DefaultPrompts; -import java.util.Objects; -import org.jetbrains.annotations.NotNull; - -public class GeminiLLMProvider implements LLMProvider { - private static final ObjectMapper MAPPER = new ObjectMapper(); - - private final Client client; - private final String geminiModelId; - private final Schema responseSchema; - private final float temperature; - private final float topP; - private final int maxOutputTokens; - - public GeminiLLMProvider(LLMConfiguration config) { - this.client = Client.builder() - .apiKey(config.getGeminiApiKey()) - .build(); - this.responseSchema = parseSchema(DefaultPrompts.JSON_FORMAT); - this.temperature = config.getGeminiTemperature(); - this.topP = config.getGeminiTopP(); - this.maxOutputTokens = config.getGeminiMaxOutputTokens(); - this.geminiModelId = config.getGeminiModelId(); - } - - private static Schema parseSchema(String jsonSchema) { - try { - return MAPPER.readValue(jsonSchema, Schema.class); - } catch (Exception e) { - throw new IllegalArgumentException("Invalid JSON schema: " + e.getMessage(), e); - } - } - - @Override - public @NotNull String generate(@NotNull String systemInstruction, @NotNull String userContent) { - final GenerateContentConfig requestConfig = GenerateContentConfig.builder() - .temperature(temperature) - .topP(topP) - .maxOutputTokens(maxOutputTokens) - .responseMimeType("application/json") - .responseSchema(responseSchema) - .systemInstruction(Content.fromParts(Part.fromText(systemInstruction))) - .build(); - - final GenerateContentResponse result = client.models.generateContent(geminiModelId, userContent, requestConfig); - - return Objects.requireNonNull(result.text(), "Failed to get response from Gemini API."); - } - - @Override - public boolean isConnected() { - return client != null; - } -} +package gg.modl.backend.ai.external; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.genai.Client; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentConfig; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import gg.modl.backend.ai.LLMConfiguration; +import gg.modl.backend.ai.data.DefaultPrompts; +import java.util.Objects; +import org.jetbrains.annotations.NotNull; + +public class GeminiLLMProvider implements LLMProvider { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final Client client; + private final String geminiModelId; + private final Schema responseSchema; + private final float temperature; + private final float topP; + private final int maxOutputTokens; + + public GeminiLLMProvider(LLMConfiguration config) { + this.client = Client.builder() + .apiKey(config.getGeminiApiKey()) + .build(); + this.responseSchema = parseSchema(DefaultPrompts.JSON_FORMAT); + this.temperature = config.getGeminiTemperature(); + this.topP = config.getGeminiTopP(); + this.maxOutputTokens = config.getGeminiMaxOutputTokens(); + this.geminiModelId = config.getGeminiModelId(); + } + + private static Schema parseSchema(String jsonSchema) { + try { + return MAPPER.readValue(jsonSchema, Schema.class); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid JSON schema: " + e.getMessage(), e); + } + } + + @Override + public @NotNull String generate(@NotNull String systemInstruction, @NotNull String userContent) { + final GenerateContentConfig requestConfig = GenerateContentConfig.builder() + .temperature(temperature) + .topP(topP) + .maxOutputTokens(maxOutputTokens) + .responseMimeType("application/json") + .responseSchema(responseSchema) + .systemInstruction(Content.fromParts(Part.fromText(systemInstruction))) + .build(); + + final GenerateContentResponse result = client.models.generateContent(geminiModelId, userContent, requestConfig); + + return Objects.requireNonNull(result.text(), "Failed to get response from Gemini API."); + } + + @Override + public boolean isConnected() { + return client != null; + } +} diff --git a/src/main/java/gg/modl/backend/ai/external/LLMProvider.java b/src/main/java/gg/modl/backend/ai/external/LLMProvider.java index 61e80e8..ecd3b65 100644 --- a/src/main/java/gg/modl/backend/ai/external/LLMProvider.java +++ b/src/main/java/gg/modl/backend/ai/external/LLMProvider.java @@ -1,10 +1,10 @@ -package gg.modl.backend.ai.external; - -import org.jetbrains.annotations.NotNull; - -public interface LLMProvider { - @NotNull - String generate(@NotNull String systemInstruction, @NotNull String userContent); - - boolean isConnected(); -} +package gg.modl.backend.ai.external; + +import org.jetbrains.annotations.NotNull; + +public interface LLMProvider { + @NotNull + String generate(@NotNull String systemInstruction, @NotNull String userContent); + + boolean isConnected(); +} diff --git a/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java b/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java index bc8dc42..c97b4d7 100644 --- a/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java +++ b/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java @@ -1,512 +1,512 @@ -package gg.modl.backend.ai.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.admin.data.SystemPrompt; -import gg.modl.backend.ai.LLMService; -import gg.modl.backend.ai.data.AIAnalysisResult; -import gg.modl.backend.ai.data.DefaultPrompts; -import gg.modl.backend.billing.service.UsageTrackingService; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.limits.ServerLimits; -import gg.modl.backend.player.dto.request.CreatePunishmentRequest; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.AIModerationSettings; -import gg.modl.backend.settings.data.AIModerationSettings.AIPunishmentConfig; -import gg.modl.backend.settings.service.AIModerationSettingsService; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketNote; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Date; -import java.util.HexFormat; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AITicketAnalysisService { - private final LLMService llmService; - private final AIModerationSettingsService aiModerationSettingsService; - private final TicketMongoRepository ticketRepository; - private final ServerMongoRepository serverRepository; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentTypeService punishmentTypeService; - private final UsageTrackingService usageTrackingService; - private final ServerLimitPolicy serverLimitPolicy; - private final ObjectMapper objectMapper; - private final SystemPromptMongoRepository systemPromptRepository; - private final StaffMongoRepository staffRepository; - public static final String AI_MODERATOR = "AI Moderator"; - private static final String DEFAULT_ISSUER_NAME = "Staff"; - private static final double AUTOMATED_ACTION_CONFIDENCE_THRESHOLD = 0.85; - private static final String REPORTED_PLAYER_REFERENCE = "the reported player identified in the untrusted chat data"; - private static final SecureRandom NONCE_RANDOM = new SecureRandom(); - - @Async - public void analyzeTicketAsync(@NotNull Server server, @NotNull String ticketId) { - final AIModerationSettings settings = resolveActiveModerationSettings(server); - if (settings == null) { - log.debug("Skipping AI analysis for ticket {}: preconditions not met", ticketId); - return; - } - - final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); - - if (ticket == null) { - log.debug("Ticket {} not found for AI analysis", ticketId); - return; - } - - if (!isChatReport(ticket)) { - log.debug("Skipping AI analysis for ticket {}: not a chat report", ticketId); - return; - } - - if (ticket.getChatMessages() == null || ticket.getChatMessages().isEmpty()) { - log.debug("Skipping AI analysis for ticket {}: no chat messages", ticketId); - return; - } - - final ModerationPrompt prompt = buildModerationPrompt(ticket, settings); - if (prompt == null) { - return; - } - - final String rawResponse; - try { - rawResponse = llmService.generate(prompt.systemInstruction(), prompt.userContent()); - } catch (Exception e) { - log.error("LLM generation failed for ticket {}", ticketId, e); - return; - } - final AIAnalysisResult result = parseResponse(rawResponse); - if (result == null) { - return; - } - - usageTrackingService.incrementAiRequests(server.getId(), 1); - - ticket.setAiAnalysis(result); - if (result.getSuggestedAction() != null && settings.isEnableAutomatedActions()) { - executeAutomatedAction(server, ticket, result, settings); - } - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - } - - @Nullable - private AIModerationSettings resolveActiveModerationSettings(@NotNull Server server) { - if (!llmService.isAvailable()) { - log.debug("LLM service not available"); - return null; - } - - final ServerLimits limits = serverLimitPolicy.resolve(server); - if (!limits.isAiModerationEnabled()) { - return null; - } - - final AIModerationSettings settings = aiModerationSettingsService.getAIModerationSettings(server); - if (!settings.isEnableAIReview() - || settings.getAiPunishmentConfigs() == null - || settings.getAiPunishmentConfigs().isEmpty()) { - return null; - } - - final ServerMongoRepository.AIUsageSnapshot usageSnapshot = serverRepository.findAIUsageSnapshotById(server.getId()).orElse(null); - if (usageSnapshot != null) { - long currentUsage = usageSnapshot.aiRequestsCurrentPeriod(); - long limit = limits.getAiRequestLimit(); - if (currentUsage >= limit) { - log.debug("Server {} has reached AI request limit ({}/{})", server.getServerName(), currentUsage, limit); - return null; - } - } - - return settings; - } - - private boolean isChatReport(Ticket ticket) { - return ticket.getType() == TicketCategory.CHAT; - } - - private void executeAutomatedAction(Server server, Ticket ticket, AIAnalysisResult result, AIModerationSettings settings) { - final AIAnalysisResult.SuggestedAction suggestion = result.getSuggestedAction(); - if (suggestion == null) { - return; - } - - if (!settings.isEnableAutomatedActions()) { - return; - } - - final AIAnalysisResult existing = ticket.getAiAnalysis(); - if (existing != null && (existing.isWasAppliedAutomatically() || existing.isDismissed())) { - return; - } - - if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { - return; - } - - final Double confidence = result.getConfidence(); - if (confidence == null || confidence < AUTOMATED_ACTION_CONFIDENCE_THRESHOLD) { - log.info("Skipping automated action for ticket {}: confidence {} below threshold {}. Leaving suggestion for human review.", - ticket.getId(), confidence, AUTOMATED_ACTION_CONFIDENCE_THRESHOLD); - return; - } - - if (settings.getAiPunishmentConfigs() == null) { - log.warn("No punishment configs available for automated action on ticket {}", ticket.getId()); - return; - } - - final String typeKey = String.valueOf(suggestion.getPunishmentTypeId()); - final AIPunishmentConfig punishmentConfig = settings.getAiPunishmentConfigs().get(typeKey); - - if (punishmentConfig == null || !punishmentConfig.isEnabled()) { - log.warn("Punishment config not found or disabled for type ordinal {}", typeKey); - return; - } - - final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); - if (playerUuid == null) { - log.warn("Cannot execute automated action: no valid reported player UUID for ticket {}", ticket.getId()); - return; - } - - try { - applyPunishmentAndCloseTicket(server, ticket, result, playerUuid, AI_MODERATOR); - result.setWasAppliedAutomatically(true); - } catch (Exception e) { - log.error("Failed to apply automated punishment for ticket {}", ticket.getId(), e); - } - } - - @Nullable - private UUID parsePlayerUuid(@Nullable String raw) { - if (raw == null || raw.isBlank()) { - return null; - } - try { - return UUID.fromString(raw); - } catch (IllegalArgumentException e) { - return null; - } - } - - private void applyPunishmentAndCloseTicket(Server server, Ticket ticket, AIAnalysisResult aiAnalysis, UUID playerUuid, String staffName) { - AIAnalysisResult.SuggestedAction suggestion = aiAnalysis.getSuggestedAction(); - String reason = aiAnalysis.getAnalysis(); - - CreatePunishmentRequest request = new CreatePunishmentRequest( - staffName, - null, - suggestion.getPunishmentTypeId(), - null, null, - List.of(ticket.getId()), - suggestion.getSeverity(), - "active", - Map.of("aiGenerated", true), - reason, null - ); - - punishmentLifecycleService.createPunishment(server, playerUuid, request); - - Date now = new Date(); - String typeName = punishmentTypeService.getPunishmentTypeName(server, suggestion.getPunishmentTypeId()); - - TicketReply systemReply = TicketReply.builder() - .id(UUID.randomUUID().toString()) - .name(staffName) - .content("This report has been reviewed and appropriate action has been taken. Thank you for your report.") - .type("system") - .created(now) - .staff(true) - .action("Close") - .attachments(new ArrayList<>()) - .build(); - - TicketNote staffNote = TicketNote.builder() - .text("AI Analysis by " + staffName + ": " + typeName + " (" + suggestion.getSeverity() + "). Reason: " + reason) - .issuerName(staffName) - .date(now) - .build(); - - if (ticket.getReplies() == null) { - ticket.setReplies(new ArrayList<>()); - } - if (ticket.getNotes() == null) { - ticket.setNotes(new ArrayList<>()); - } - ticket.getReplies().add(systemReply); - ticket.getNotes().add(staffNote); - ticket.setStatus(TicketStatus.CLOSED); - ticket.setLocked(true); - ticket.setUpdatedAt(now); - } - - private String getSystemPrompt() { - SystemPrompt prompt = systemPromptRepository.findActive().orElse(null); - - if (prompt != null && prompt.getPrompt() != null && !prompt.getPrompt().isBlank()) { - return prompt.getPrompt(); - } - - return getDefaultPrompt(); - } - - @NotNull - public static String getDefaultPrompt() { - return DefaultPrompts.MINECRAFT; - } - - @Nullable - private ModerationPrompt buildModerationPrompt(@NotNull Ticket ticket, @NotNull AIModerationSettings settings) { - final String nonce = generateNonce(); - final String beginMarker = "===BEGIN_UNTRUSTED_CHAT_DATA:" + nonce + "==="; - final String endMarker = "===END_UNTRUSTED_CHAT_DATA:" + nonce + "==="; - - final String chatJson; - try { - chatJson = objectMapper.writeValueAsString(buildChatPayload(ticket)); - } catch (JsonProcessingException e) { - log.error("Failed to serialize chat data for ticket {}", ticket.getId(), e); - return null; - } - - final String userContent = beginMarker + "\n" + chatJson + "\n" + endMarker; - final String systemInstruction = getSystemPrompt() - .replace("{{REPORTED_PLAYER}}", REPORTED_PLAYER_REFERENCE) - .replace("{{PUNISHMENT_TYPES}}", formatPunishmentTypes(settings)) - .replace("{{CHAT_LOG}}", "") - + "\n\n" - + DefaultPrompts.UNTRUSTED_DATA_DIRECTIVE.formatted(beginMarker, endMarker); - - return new ModerationPrompt(systemInstruction, userContent); - } - - @NotNull - private Map buildChatPayload(@NotNull Ticket ticket) { - final List> messages = new ArrayList<>(); - for (Ticket.ChatMessage message : ticket.getChatMessages()) { - final Map entry = new LinkedHashMap<>(); - entry.put("sender", message.getSender()); - entry.put("content", message.getContent()); - messages.add(entry); - } - - final Map payload = new LinkedHashMap<>(); - payload.put("reportedPlayer", ticket.getReportedPlayer()); - payload.put("messages", messages); - return payload; - } - - @NotNull - private static String generateNonce() { - final byte[] bytes = new byte[16]; - NONCE_RANDOM.nextBytes(bytes); - return HexFormat.of().formatHex(bytes); - } - - @NotNull - private String formatPunishmentTypes(@NotNull AIModerationSettings settings) { - if (settings.getAiPunishmentConfigs() == null || settings.getAiPunishmentConfigs().isEmpty()) { - return "No punishment types configured"; - } - - return settings.getAiPunishmentConfigs().values() - .stream() - .filter(AIPunishmentConfig::isEnabled) - .map(config -> { - String description = config.getAiDescription(); - return "%s: (%s) %s".formatted( - config.getId(), - config.getName(), - description != null && !description.isBlank() ? description : config.getName() - ); - }) - .collect(Collectors.joining("\n")); - } - - @Nullable - private AIAnalysisResult parseResponse(@NotNull String rawResponse) { - try { - final String jsonContent = extractJson(rawResponse); - final JsonNode json = objectMapper.readTree(jsonContent); - final String analysis = json.has("analysis") ? json.get("analysis").asText() : null; - - if (analysis == null) { - return null; - } - - AIAnalysisResult.SuggestedAction suggestedAction = null; - if (json.has("suggestedAction") && !json.get("suggestedAction").isNull()) { - JsonNode actionNode = json.get("suggestedAction"); - final Integer punishmentTypeId = parseIntField(actionNode, "punishmentTypeId"); - final JsonNode sevNode = actionNode.path("severity"); - final String severity = (sevNode.isMissingNode() || sevNode.isNull()) ? null : sevNode.asText(); - - if (punishmentTypeId != null && severity != null) { - suggestedAction = new AIAnalysisResult.SuggestedAction(punishmentTypeId, severity); - } - } - - final AIAnalysisResult result = new AIAnalysisResult(analysis, suggestedAction, new Date(), rawResponse); - result.setConfidence(parseDoubleField(json, "confidence")); - return result; - } catch (Exception e) { - log.error("Failed to parse AI response: {}", rawResponse, e); - return null; - } - } - - @Nullable - private Double parseDoubleField(@Nullable JsonNode node, @Nullable String field) { - if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { - return null; - } - - final JsonNode value = node.get(field); - if (value.isNumber()) { - return value.asDouble(); - } - - if (value.isTextual()) { - try { - return Double.parseDouble(value.asText()); - } catch (NumberFormatException e) { - log.warn("Non-numeric value for {}: {}", field, value.asText()); - return null; - } - } - - return null; - } - - @Nullable - private Integer parseIntField(@Nullable JsonNode node, @Nullable String field) { - if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { - return null; - } - - final JsonNode value = node.get(field); - if (value.isNumber()) { - return value.asInt(); - } - - if (value.isTextual()) { - try { - return Integer.parseInt(value.asText()); - } catch (NumberFormatException e) { - log.warn("Non-numeric value for {}: {}", field, value.asText()); - return null; - } - } - - return null; - } - - @NotNull - private String extractJson(@NotNull String response) { - final String trimmed = response.trim(); - final int start = trimmed.indexOf('{'); - final int end = trimmed.lastIndexOf('}'); - - if (start != -1 && end != -1 && end > start) { - return trimmed.substring(start, end + 1); - } - - return trimmed; - } - - @NotNull - public AISuggestionResult applyAISuggestion(@NotNull Server server, @NotNull String ticketId, @Nullable String actingEmail) { - final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); - - if (ticket == null) { - return new AISuggestionResult(false, "Ticket not found"); - } - - final AIAnalysisResult aiAnalysis = ticket.getAiAnalysis(); - if (aiAnalysis == null || aiAnalysis.getSuggestedAction() == null) { - return new AISuggestionResult(false, "No AI suggestion to apply"); - } - - if (aiAnalysis.isWasAppliedAutomatically() || aiAnalysis.isDismissed()) { - return new AISuggestionResult(false, "AI suggestion already handled"); - } - - if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { - return new AISuggestionResult(false, "Ticket already closed"); - } - - final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); - if (playerUuid == null) { - return new AISuggestionResult(false, "No valid reported player UUID"); - } - - punishmentLifecycleService.validatePunishmentPermission(server, actingEmail, aiAnalysis.getSuggestedAction().getPunishmentTypeId()); - - applyPunishmentAndCloseTicket(server, ticket, aiAnalysis, playerUuid, resolveIssuerName(server, actingEmail)); - ticketRepository.saveEntity(server, ticket); - - return new AISuggestionResult(true, null); - } - - @NotNull - private String resolveIssuerName(@NotNull Server server, @Nullable String email) { - if (email == null) { - return DEFAULT_ISSUER_NAME; - } - return staffRepository.findByEmailIgnoreCase(server, email) - .map(Staff::getUsername) - .filter(name -> name != null && !name.isBlank()) - .orElse(DEFAULT_ISSUER_NAME); - } - - @NotNull - public AISuggestionResult dismissAISuggestion(@NotNull Server server, @NotNull String ticketId) { - final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); - - if (ticket == null) { - return new AISuggestionResult(false, "Ticket not found"); - } - - if (ticket.getAiAnalysis() == null) { - return new AISuggestionResult(false, "No AI analysis to dismiss"); - } - - ticket.getAiAnalysis().setDismissed(true); - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - - return new AISuggestionResult(true, null); - } - - public record AISuggestionResult(boolean success, String error) {} - - private record ModerationPrompt(String systemInstruction, String userContent) {} -} +package gg.modl.backend.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.admin.data.SystemPrompt; +import gg.modl.backend.ai.LLMService; +import gg.modl.backend.ai.data.AIAnalysisResult; +import gg.modl.backend.ai.data.DefaultPrompts; +import gg.modl.backend.billing.service.UsageTrackingService; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.limits.ServerLimits; +import gg.modl.backend.player.dto.request.CreatePunishmentRequest; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.AIModerationSettings; +import gg.modl.backend.settings.data.AIModerationSettings.AIPunishmentConfig; +import gg.modl.backend.settings.service.AIModerationSettingsService; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketNote; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Date; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AITicketAnalysisService { + private final LLMService llmService; + private final AIModerationSettingsService aiModerationSettingsService; + private final TicketMongoRepository ticketRepository; + private final ServerMongoRepository serverRepository; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentTypeService punishmentTypeService; + private final UsageTrackingService usageTrackingService; + private final ServerLimitPolicy serverLimitPolicy; + private final ObjectMapper objectMapper; + private final SystemPromptMongoRepository systemPromptRepository; + private final StaffMongoRepository staffRepository; + public static final String AI_MODERATOR = "AI Moderator"; + private static final String DEFAULT_ISSUER_NAME = "Staff"; + private static final double AUTOMATED_ACTION_CONFIDENCE_THRESHOLD = 0.85; + private static final String REPORTED_PLAYER_REFERENCE = "the reported player identified in the untrusted chat data"; + private static final SecureRandom NONCE_RANDOM = new SecureRandom(); + + @Async + public void analyzeTicketAsync(@NotNull Server server, @NotNull String ticketId) { + final AIModerationSettings settings = resolveActiveModerationSettings(server); + if (settings == null) { + log.debug("Skipping AI analysis for ticket {}: preconditions not met", ticketId); + return; + } + + final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); + + if (ticket == null) { + log.debug("Ticket {} not found for AI analysis", ticketId); + return; + } + + if (!isChatReport(ticket)) { + log.debug("Skipping AI analysis for ticket {}: not a chat report", ticketId); + return; + } + + if (ticket.getChatMessages() == null || ticket.getChatMessages().isEmpty()) { + log.debug("Skipping AI analysis for ticket {}: no chat messages", ticketId); + return; + } + + final ModerationPrompt prompt = buildModerationPrompt(ticket, settings); + if (prompt == null) { + return; + } + + final String rawResponse; + try { + rawResponse = llmService.generate(prompt.systemInstruction(), prompt.userContent()); + } catch (Exception e) { + log.error("LLM generation failed for ticket {}", ticketId, e); + return; + } + final AIAnalysisResult result = parseResponse(rawResponse); + if (result == null) { + return; + } + + usageTrackingService.incrementAiRequests(server.getId(), 1); + + ticket.setAiAnalysis(result); + if (result.getSuggestedAction() != null && settings.isEnableAutomatedActions()) { + executeAutomatedAction(server, ticket, result, settings); + } + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + } + + @Nullable + private AIModerationSettings resolveActiveModerationSettings(@NotNull Server server) { + if (!llmService.isAvailable()) { + log.debug("LLM service not available"); + return null; + } + + final ServerLimits limits = serverLimitPolicy.resolve(server); + if (!limits.isAiModerationEnabled()) { + return null; + } + + final AIModerationSettings settings = aiModerationSettingsService.getAIModerationSettings(server); + if (!settings.isEnableAIReview() + || settings.getAiPunishmentConfigs() == null + || settings.getAiPunishmentConfigs().isEmpty()) { + return null; + } + + final ServerMongoRepository.AIUsageSnapshot usageSnapshot = serverRepository.findAIUsageSnapshotById(server.getId()).orElse(null); + if (usageSnapshot != null) { + long currentUsage = usageSnapshot.aiRequestsCurrentPeriod(); + long limit = limits.getAiRequestLimit(); + if (currentUsage >= limit) { + log.debug("Server {} has reached AI request limit ({}/{})", server.getServerName(), currentUsage, limit); + return null; + } + } + + return settings; + } + + private boolean isChatReport(Ticket ticket) { + return ticket.getType() == TicketCategory.CHAT; + } + + private void executeAutomatedAction(Server server, Ticket ticket, AIAnalysisResult result, AIModerationSettings settings) { + final AIAnalysisResult.SuggestedAction suggestion = result.getSuggestedAction(); + if (suggestion == null) { + return; + } + + if (!settings.isEnableAutomatedActions()) { + return; + } + + final AIAnalysisResult existing = ticket.getAiAnalysis(); + if (existing != null && (existing.isWasAppliedAutomatically() || existing.isDismissed())) { + return; + } + + if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { + return; + } + + final Double confidence = result.getConfidence(); + if (confidence == null || confidence < AUTOMATED_ACTION_CONFIDENCE_THRESHOLD) { + log.info("Skipping automated action for ticket {}: confidence {} below threshold {}. Leaving suggestion for human review.", + ticket.getId(), confidence, AUTOMATED_ACTION_CONFIDENCE_THRESHOLD); + return; + } + + if (settings.getAiPunishmentConfigs() == null) { + log.warn("No punishment configs available for automated action on ticket {}", ticket.getId()); + return; + } + + final String typeKey = String.valueOf(suggestion.getPunishmentTypeId()); + final AIPunishmentConfig punishmentConfig = settings.getAiPunishmentConfigs().get(typeKey); + + if (punishmentConfig == null || !punishmentConfig.isEnabled()) { + log.warn("Punishment config not found or disabled for type ordinal {}", typeKey); + return; + } + + final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); + if (playerUuid == null) { + log.warn("Cannot execute automated action: no valid reported player UUID for ticket {}", ticket.getId()); + return; + } + + try { + applyPunishmentAndCloseTicket(server, ticket, result, playerUuid, AI_MODERATOR); + result.setWasAppliedAutomatically(true); + } catch (Exception e) { + log.error("Failed to apply automated punishment for ticket {}", ticket.getId(), e); + } + } + + @Nullable + private UUID parsePlayerUuid(@Nullable String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException e) { + return null; + } + } + + private void applyPunishmentAndCloseTicket(Server server, Ticket ticket, AIAnalysisResult aiAnalysis, UUID playerUuid, String staffName) { + AIAnalysisResult.SuggestedAction suggestion = aiAnalysis.getSuggestedAction(); + String reason = aiAnalysis.getAnalysis(); + + CreatePunishmentRequest request = new CreatePunishmentRequest( + staffName, + null, + suggestion.getPunishmentTypeId(), + null, null, + List.of(ticket.getId()), + suggestion.getSeverity(), + "active", + Map.of("aiGenerated", true), + reason, null + ); + + punishmentLifecycleService.createPunishment(server, playerUuid, request); + + Date now = new Date(); + String typeName = punishmentTypeService.getPunishmentTypeName(server, suggestion.getPunishmentTypeId()); + + TicketReply systemReply = TicketReply.builder() + .id(UUID.randomUUID().toString()) + .name(staffName) + .content("This report has been reviewed and appropriate action has been taken. Thank you for your report.") + .type("system") + .created(now) + .staff(true) + .action("Close") + .attachments(new ArrayList<>()) + .build(); + + TicketNote staffNote = TicketNote.builder() + .text("AI Analysis by " + staffName + ": " + typeName + " (" + suggestion.getSeverity() + "). Reason: " + reason) + .issuerName(staffName) + .date(now) + .build(); + + if (ticket.getReplies() == null) { + ticket.setReplies(new ArrayList<>()); + } + if (ticket.getNotes() == null) { + ticket.setNotes(new ArrayList<>()); + } + ticket.getReplies().add(systemReply); + ticket.getNotes().add(staffNote); + ticket.setStatus(TicketStatus.CLOSED); + ticket.setLocked(true); + ticket.setUpdatedAt(now); + } + + private String getSystemPrompt() { + SystemPrompt prompt = systemPromptRepository.findActive().orElse(null); + + if (prompt != null && prompt.getPrompt() != null && !prompt.getPrompt().isBlank()) { + return prompt.getPrompt(); + } + + return getDefaultPrompt(); + } + + @NotNull + public static String getDefaultPrompt() { + return DefaultPrompts.MINECRAFT; + } + + @Nullable + private ModerationPrompt buildModerationPrompt(@NotNull Ticket ticket, @NotNull AIModerationSettings settings) { + final String nonce = generateNonce(); + final String beginMarker = "===BEGIN_UNTRUSTED_CHAT_DATA:" + nonce + "==="; + final String endMarker = "===END_UNTRUSTED_CHAT_DATA:" + nonce + "==="; + + final String chatJson; + try { + chatJson = objectMapper.writeValueAsString(buildChatPayload(ticket)); + } catch (JsonProcessingException e) { + log.error("Failed to serialize chat data for ticket {}", ticket.getId(), e); + return null; + } + + final String userContent = beginMarker + "\n" + chatJson + "\n" + endMarker; + final String systemInstruction = getSystemPrompt() + .replace("{{REPORTED_PLAYER}}", REPORTED_PLAYER_REFERENCE) + .replace("{{PUNISHMENT_TYPES}}", formatPunishmentTypes(settings)) + .replace("{{CHAT_LOG}}", "") + + "\n\n" + + DefaultPrompts.UNTRUSTED_DATA_DIRECTIVE.formatted(beginMarker, endMarker); + + return new ModerationPrompt(systemInstruction, userContent); + } + + @NotNull + private Map buildChatPayload(@NotNull Ticket ticket) { + final List> messages = new ArrayList<>(); + for (Ticket.ChatMessage message : ticket.getChatMessages()) { + final Map entry = new LinkedHashMap<>(); + entry.put("sender", message.getSender()); + entry.put("content", message.getContent()); + messages.add(entry); + } + + final Map payload = new LinkedHashMap<>(); + payload.put("reportedPlayer", ticket.getReportedPlayer()); + payload.put("messages", messages); + return payload; + } + + @NotNull + private static String generateNonce() { + final byte[] bytes = new byte[16]; + NONCE_RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + @NotNull + private String formatPunishmentTypes(@NotNull AIModerationSettings settings) { + if (settings.getAiPunishmentConfigs() == null || settings.getAiPunishmentConfigs().isEmpty()) { + return "No punishment types configured"; + } + + return settings.getAiPunishmentConfigs().values() + .stream() + .filter(AIPunishmentConfig::isEnabled) + .map(config -> { + String description = config.getAiDescription(); + return "%s: (%s) %s".formatted( + config.getId(), + config.getName(), + description != null && !description.isBlank() ? description : config.getName() + ); + }) + .collect(Collectors.joining("\n")); + } + + @Nullable + private AIAnalysisResult parseResponse(@NotNull String rawResponse) { + try { + final String jsonContent = extractJson(rawResponse); + final JsonNode json = objectMapper.readTree(jsonContent); + final String analysis = json.has("analysis") ? json.get("analysis").asText() : null; + + if (analysis == null) { + return null; + } + + AIAnalysisResult.SuggestedAction suggestedAction = null; + if (json.has("suggestedAction") && !json.get("suggestedAction").isNull()) { + JsonNode actionNode = json.get("suggestedAction"); + final Integer punishmentTypeId = parseIntField(actionNode, "punishmentTypeId"); + final JsonNode sevNode = actionNode.path("severity"); + final String severity = (sevNode.isMissingNode() || sevNode.isNull()) ? null : sevNode.asText(); + + if (punishmentTypeId != null && severity != null) { + suggestedAction = new AIAnalysisResult.SuggestedAction(punishmentTypeId, severity); + } + } + + final AIAnalysisResult result = new AIAnalysisResult(analysis, suggestedAction, new Date(), rawResponse); + result.setConfidence(parseDoubleField(json, "confidence")); + return result; + } catch (Exception e) { + log.error("Failed to parse AI response: {}", rawResponse, e); + return null; + } + } + + @Nullable + private Double parseDoubleField(@Nullable JsonNode node, @Nullable String field) { + if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { + return null; + } + + final JsonNode value = node.get(field); + if (value.isNumber()) { + return value.asDouble(); + } + + if (value.isTextual()) { + try { + return Double.parseDouble(value.asText()); + } catch (NumberFormatException e) { + log.warn("Non-numeric value for {}: {}", field, value.asText()); + return null; + } + } + + return null; + } + + @Nullable + private Integer parseIntField(@Nullable JsonNode node, @Nullable String field) { + if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { + return null; + } + + final JsonNode value = node.get(field); + if (value.isNumber()) { + return value.asInt(); + } + + if (value.isTextual()) { + try { + return Integer.parseInt(value.asText()); + } catch (NumberFormatException e) { + log.warn("Non-numeric value for {}: {}", field, value.asText()); + return null; + } + } + + return null; + } + + @NotNull + private String extractJson(@NotNull String response) { + final String trimmed = response.trim(); + final int start = trimmed.indexOf('{'); + final int end = trimmed.lastIndexOf('}'); + + if (start != -1 && end != -1 && end > start) { + return trimmed.substring(start, end + 1); + } + + return trimmed; + } + + @NotNull + public AISuggestionResult applyAISuggestion(@NotNull Server server, @NotNull String ticketId, @Nullable String actingEmail) { + final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); + + if (ticket == null) { + return new AISuggestionResult(false, "Ticket not found"); + } + + final AIAnalysisResult aiAnalysis = ticket.getAiAnalysis(); + if (aiAnalysis == null || aiAnalysis.getSuggestedAction() == null) { + return new AISuggestionResult(false, "No AI suggestion to apply"); + } + + if (aiAnalysis.isWasAppliedAutomatically() || aiAnalysis.isDismissed()) { + return new AISuggestionResult(false, "AI suggestion already handled"); + } + + if (ticket.getStatus() == TicketStatus.CLOSED || ticket.isLocked()) { + return new AISuggestionResult(false, "Ticket already closed"); + } + + final UUID playerUuid = parsePlayerUuid(ticket.getReportedPlayerUuid()); + if (playerUuid == null) { + return new AISuggestionResult(false, "No valid reported player UUID"); + } + + punishmentLifecycleService.validatePunishmentPermission(server, actingEmail, aiAnalysis.getSuggestedAction().getPunishmentTypeId()); + + applyPunishmentAndCloseTicket(server, ticket, aiAnalysis, playerUuid, resolveIssuerName(server, actingEmail)); + ticketRepository.saveEntity(server, ticket); + + return new AISuggestionResult(true, null); + } + + @NotNull + private String resolveIssuerName(@NotNull Server server, @Nullable String email) { + if (email == null) { + return DEFAULT_ISSUER_NAME; + } + return staffRepository.findByEmailIgnoreCase(server, email) + .map(Staff::getUsername) + .filter(name -> name != null && !name.isBlank()) + .orElse(DEFAULT_ISSUER_NAME); + } + + @NotNull + public AISuggestionResult dismissAISuggestion(@NotNull Server server, @NotNull String ticketId) { + final Ticket ticket = ticketRepository.findById(server, ticketId).orElse(null); + + if (ticket == null) { + return new AISuggestionResult(false, "Ticket not found"); + } + + if (ticket.getAiAnalysis() == null) { + return new AISuggestionResult(false, "No AI analysis to dismiss"); + } + + ticket.getAiAnalysis().setDismissed(true); + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + + return new AISuggestionResult(true, null); + } + + public record AISuggestionResult(boolean success, String error) {} + + private record ModerationPrompt(String systemInstruction, String userContent) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java b/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java index a897631..1367e91 100644 --- a/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java +++ b/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java @@ -1,78 +1,78 @@ -package gg.modl.backend.analytics.controller; - -import gg.modl.backend.analytics.dto.response.AuditLogsAnalyticsResponse; -import gg.modl.backend.analytics.dto.response.OverviewResponse; -import gg.modl.backend.analytics.dto.response.PlayerActivityResponse; -import gg.modl.backend.analytics.dto.response.PunishmentAnalyticsResponse; -import gg.modl.backend.analytics.dto.response.TicketAnalyticsResponse; -import gg.modl.backend.analytics.service.AnalyticsService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.AnalyticsOverviewResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_ANALYTICS) -@RequiredArgsConstructor -public class AnalyticsController { - private final AnalyticsService analyticsService; - - @GetMapping("/overview") - public ResponseEntity getOverview(HttpServletRequest request) { - final Server server = RequestUtil.getRequestServer(request); - final OverviewResponse overview = analyticsService.getOverview(server); - - return ResponseEntity.ok(AnalyticsProtoMapper.toOverviewResponse(overview)); - } - - @GetMapping("/tickets") - public ResponseEntity getTicketAnalytics( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final TicketAnalyticsResponse analytics = analyticsService.getTicketAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toTicketAnalytics(analytics)); - } - - @GetMapping("/punishments") - public ResponseEntity getPunishmentAnalytics( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final PunishmentAnalyticsResponse analytics = analyticsService.getPunishmentAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toPunishmentAnalytics(analytics)); - } - - @GetMapping("/audit-logs") - public ResponseEntity getAuditLogsAnalytics( - @RequestParam(defaultValue = "7d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final AuditLogsAnalyticsResponse analytics = analyticsService.getAuditLogsAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toAuditLogsAnalytics(analytics)); - } - - @GetMapping("/player-activity") - public ResponseEntity getPlayerActivityAnalytics( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - final Server server = RequestUtil.getRequestServer(request); - final PlayerActivityResponse analytics = analyticsService.getPlayerActivityAnalytics(server, period); - - return ResponseEntity.ok(AnalyticsProtoMapper.toPlayerActivity(analytics)); - } -} +package gg.modl.backend.analytics.controller; + +import gg.modl.backend.analytics.dto.response.AuditLogsAnalyticsResponse; +import gg.modl.backend.analytics.dto.response.OverviewResponse; +import gg.modl.backend.analytics.dto.response.PlayerActivityResponse; +import gg.modl.backend.analytics.dto.response.PunishmentAnalyticsResponse; +import gg.modl.backend.analytics.dto.response.TicketAnalyticsResponse; +import gg.modl.backend.analytics.service.AnalyticsService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.AnalyticsOverviewResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_ANALYTICS) +@RequiredArgsConstructor +public class AnalyticsController { + private final AnalyticsService analyticsService; + + @GetMapping("/overview") + public ResponseEntity getOverview(HttpServletRequest request) { + final Server server = RequestUtil.getRequestServer(request); + final OverviewResponse overview = analyticsService.getOverview(server); + + return ResponseEntity.ok(AnalyticsProtoMapper.toOverviewResponse(overview)); + } + + @GetMapping("/tickets") + public ResponseEntity getTicketAnalytics( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final TicketAnalyticsResponse analytics = analyticsService.getTicketAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toTicketAnalytics(analytics)); + } + + @GetMapping("/punishments") + public ResponseEntity getPunishmentAnalytics( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final PunishmentAnalyticsResponse analytics = analyticsService.getPunishmentAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toPunishmentAnalytics(analytics)); + } + + @GetMapping("/audit-logs") + public ResponseEntity getAuditLogsAnalytics( + @RequestParam(defaultValue = "7d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final AuditLogsAnalyticsResponse analytics = analyticsService.getAuditLogsAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toAuditLogsAnalytics(analytics)); + } + + @GetMapping("/player-activity") + public ResponseEntity getPlayerActivityAnalytics( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + final Server server = RequestUtil.getRequestServer(request); + final PlayerActivityResponse analytics = analyticsService.getPlayerActivityAnalytics(server, period); + + return ResponseEntity.ok(AnalyticsProtoMapper.toPlayerActivity(analytics)); + } +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java index ff6cfc9..9d3c164 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/AuditLogsAnalyticsResponse.java @@ -1,12 +1,12 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record AuditLogsAnalyticsResponse( - List byLevel, - List hourlyTrend -) { - public record LevelCount(String level, int count) {} - - public record HourlyCount(String hour, int count) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record AuditLogsAnalyticsResponse( + List byLevel, + List hourlyTrend +) { + public record LevelCount(String level, int count) {} + + public record HourlyCount(String hour, int count) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java index 4ae03a1..24faf59 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/OverviewResponse.java @@ -1,11 +1,11 @@ -package gg.modl.backend.analytics.dto.response; - -public record OverviewResponse( - long totalTickets, - long totalPlayers, - long totalStaff, - long activeTickets, - int ticketChange, - int playerChange -) { -} +package gg.modl.backend.analytics.dto.response; + +public record OverviewResponse( + long totalTickets, + long totalPlayers, + long totalStaff, + long activeTickets, + int ticketChange, + int playerChange +) { +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java index 46d0207..d02ea3d 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/PlayerActivityResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record PlayerActivityResponse( - List newPlayersTrend, - List loginsByCountry, - SuspiciousActivity suspiciousActivity -) { - public record DailyCount(String date, int count) {} - - public record CountryCount(String country, int count) {} - - public record SuspiciousActivity(int proxyCount, int hostingCount) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record PlayerActivityResponse( + List newPlayersTrend, + List loginsByCountry, + SuspiciousActivity suspiciousActivity +) { + public record DailyCount(String date, int count) {} + + public record CountryCount(String country, int count) {} + + public record SuspiciousActivity(int proxyCount, int hostingCount) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java index 47dd8ba..cba2808 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/PunishmentAnalyticsResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record PunishmentAnalyticsResponse( - List byType, - List dailyPunishments, - List byStaff -) { - public record TypeCount(String type, int count) {} - - public record DailyPunishment(String date, int count) {} - - public record StaffPunishment(String username, int count) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record PunishmentAnalyticsResponse( + List byType, + List dailyPunishments, + List byStaff +) { + public record TypeCount(String type, int count) {} + + public record DailyPunishment(String date, int count) {} + + public record StaffPunishment(String username, int count) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java b/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java index 8a8d49e..304d20a 100644 --- a/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java +++ b/src/main/java/gg/modl/backend/analytics/dto/response/TicketAnalyticsResponse.java @@ -1,18 +1,18 @@ -package gg.modl.backend.analytics.dto.response; - -import java.util.List; - -public record TicketAnalyticsResponse( - List byStatus, - List byCategory, - List avgResolutionByCategory, - List dailyTickets -) { - public record StatusCount(String status, int count) {} - - public record CategoryCount(String category, int count) {} - - public record CategoryResolutionTime(String category, double avgHours) {} - - public record DailyTicket(String date, int count) {} -} +package gg.modl.backend.analytics.dto.response; + +import java.util.List; + +public record TicketAnalyticsResponse( + List byStatus, + List byCategory, + List avgResolutionByCategory, + List dailyTickets +) { + public record StatusCount(String status, int count) {} + + public record CategoryCount(String category, int count) {} + + public record CategoryResolutionTime(String category, double avgHours) {} + + public record DailyTicket(String date, int count) {} +} diff --git a/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java b/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java index 11e863b..eeb8dde 100644 --- a/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java +++ b/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java @@ -21,7 +21,6 @@ import gg.modl.backend.infrastructure.util.DateRangeUtil; import java.time.Duration; import java.time.Instant; -import java.time.LocalDate; import java.time.ZoneId; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -111,13 +110,9 @@ private TicketAnalyticsResponse computeTicketAnalytics(Server server, String per .map(result -> new TicketAnalyticsResponse.CategoryCount(normalizeCategory(result.id()), result.count())) .toList(); - final DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMM dd"); final List dailyTickets = analyticsRepository.aggregateDailyTicketCounts(server, startDate, ANALYTICS_TIME_ZONE) .stream() - .map(result -> new TicketAnalyticsResponse.DailyTicket( - formatDateLabel(result.id(), dateFormatter), - result.count() - )) + .map(result -> new TicketAnalyticsResponse.DailyTicket(result.id(), result.count())) .toList(); final List avgResolution = Collections.emptyList(); @@ -125,18 +120,6 @@ private TicketAnalyticsResponse computeTicketAnalytics(Server server, String per return new TicketAnalyticsResponse(byStatus, byCategory, avgResolution, dailyTickets); } - private String formatDateLabel(String dateKey, DateTimeFormatter formatter) { - if (dateKey == null || dateKey.isBlank()) { - return "Unknown"; - } - - try { - return LocalDate.parse(dateKey).format(formatter); - } catch (Exception ignored) { - return dateKey; - } - } - private String normalizeCategory(String category) { try { return TicketCategory.fromCanonicalId(category).getDisplayName(); @@ -208,7 +191,7 @@ private PunishmentAnalyticsResponse computePunishmentAnalytics(Server server, St Map dailyPunishmentMap = new LinkedHashMap<>(); for (Document document : toDocumentList(facetResults.get("daily"))) { - String dayLabel = formatPunishmentDay(document.getString("_id")); + String dayLabel = document.getString("_id"); dailyPunishmentMap.merge(dayLabel, toInt(document.get("count")), Integer::sum); } List dailyPunishments = dailyPunishmentMap.entrySet() @@ -249,12 +232,6 @@ private String normalizeStaffName(Object rawStaffName) { return normalized.isBlank() ? "Unknown" : normalized; } - private static final DateTimeFormatter SHORT_DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM dd"); - - private String formatPunishmentDay(String dateKey) { - return formatDateLabel(dateKey, SHORT_DATE_FORMATTER); - } - public AuditLogsAnalyticsResponse getAuditLogsAnalytics(Server server, String period) { return auditLogsAnalyticsCache.get(cacheKey(server, period), key -> computeAuditLogsAnalytics(server, period)); } @@ -310,11 +287,9 @@ private PlayerActivityResponse computePlayerActivityAnalytics(Server server, Str new PlayerActivityResponse.SuspiciousActivity(0, 0)); } - DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("MMM dd"); - List newPlayersTrend = toDocumentList(facetResults.get("newPlayers")).stream() .map(doc -> new PlayerActivityResponse.DailyCount( - formatDateLabel(doc.getString("_id"), dateFormatter), + doc.getString("_id"), toInt(doc.get("count")))) .toList(); diff --git a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java index 8f81184..9470290 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java @@ -1,97 +1,97 @@ -package gg.modl.backend.appeal.controller; - -import gg.modl.backend.appeal.service.AppealService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; -import gg.modl.backend.log.service.PanelActionAuditor; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.response.TicketResponse; -import gg.modl.proto.modl.v1.AddTicketReplyResponse; -import gg.modl.proto.modl.v1.AppealTicketsResponse; -import gg.modl.proto.modl.v1.PanelResource; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_APPEALS) -@RequiredArgsConstructor -public class PanelAppealController { - private final AppealService appealService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PanelActionAuditor panelActionAuditor; - - @GetMapping("/punishment/{punishmentId}") - public ResponseEntity getAppealsByPunishment( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List appeals = appealService.getAppealsByPunishment(server, punishmentId); - - if (appeals.isEmpty()) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok(PanelAppealProtoMapper.toAppealTicketsResponse(appeals)); - } - - @GetMapping("/{id}") - public ResponseEntity getAppealById( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appealService.getAppealById(server, id))); - } - - @PostMapping("/{id}/replies") - public ResponseEntity addReply( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - TicketReply reply = appealService.addReply(server, id, PanelAppealProtoMapper.fromAddAppealReplyRequest(replyRequest)); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); - return ResponseEntity.status(HttpStatus.CREATED).body(PanelAppealProtoMapper.toAddReplyResponse(reply)); - } - - @PatchMapping("/{id}/status") - public ResponseEntity updateStatus( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.UpdateAppealStatusRequest statusRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - UpdateAppealStatusRequest command = PanelAppealProtoMapper.fromUpdateAppealStatusRequest(statusRequest); - TicketResponse appeal = appealService.updateStatus(server, id, command); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_PUNISHMENTS); - String actorEmail = RequestUtil.getSessionEmail(request); - if (command.status() != null) { - panelActionAuditor.recordModerationAction(server, actorEmail, describeAppealDecision(id, appeal, command)); - } else { - panelActionAuditor.recordStaffAction(server, actorEmail, "Updated appeal " + id); - } - return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appeal)); - } - - private static String describeAppealDecision(String appealId, TicketResponse appeal, UpdateAppealStatusRequest command) { - String resolution = command.resolution(); - String resolutionSuffix = resolution != null && !resolution.isBlank() ? " (" + resolution + ")" : ""; - return "Appeal " + appealId + " " + appeal.status() + resolutionSuffix; - } -} +package gg.modl.backend.appeal.controller; + +import gg.modl.backend.appeal.service.AppealService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; +import gg.modl.backend.log.service.PanelActionAuditor; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.response.TicketResponse; +import gg.modl.proto.modl.v1.AddTicketReplyResponse; +import gg.modl.proto.modl.v1.AppealTicketsResponse; +import gg.modl.proto.modl.v1.PanelResource; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_APPEALS) +@RequiredArgsConstructor +public class PanelAppealController { + private final AppealService appealService; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PanelActionAuditor panelActionAuditor; + + @GetMapping("/punishment/{punishmentId}") + public ResponseEntity getAppealsByPunishment( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List appeals = appealService.getAppealsByPunishment(server, punishmentId); + + if (appeals.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(PanelAppealProtoMapper.toAppealTicketsResponse(appeals)); + } + + @GetMapping("/{id}") + public ResponseEntity getAppealById( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appealService.getAppealById(server, id))); + } + + @PostMapping("/{id}/replies") + public ResponseEntity addReply( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + TicketReply reply = appealService.addReply(server, id, PanelAppealProtoMapper.fromAddAppealReplyRequest(replyRequest)); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); + return ResponseEntity.status(HttpStatus.CREATED).body(PanelAppealProtoMapper.toAddReplyResponse(reply)); + } + + @PatchMapping("/{id}/status") + public ResponseEntity updateStatus( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.UpdateAppealStatusRequest statusRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + UpdateAppealStatusRequest command = PanelAppealProtoMapper.fromUpdateAppealStatusRequest(statusRequest); + TicketResponse appeal = appealService.updateStatus(server, id, command); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_PUNISHMENTS); + String actorEmail = RequestUtil.getSessionEmail(request); + if (command.status() != null) { + panelActionAuditor.recordModerationAction(server, actorEmail, describeAppealDecision(id, appeal, command)); + } else { + panelActionAuditor.recordStaffAction(server, actorEmail, "Updated appeal " + id); + } + return ResponseEntity.ok(PanelAppealProtoMapper.toTicketResponse(appeal)); + } + + private static String describeAppealDecision(String appealId, TicketResponse appeal, UpdateAppealStatusRequest command) { + String resolution = command.resolution(); + String resolutionSuffix = resolution != null && !resolution.isBlank() ? " (" + resolution + ")" : ""; + return "Appeal " + appealId + " " + appeal.status() + resolutionSuffix; + } +} diff --git a/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java b/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java index 430c130..f5653f4 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java @@ -1,128 +1,128 @@ -package gg.modl.backend.appeal.controller; - -import gg.modl.backend.appeal.service.AppealService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.controller.PublicVerificationProtoMapper; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.response.TicketResponse; -import gg.modl.backend.ticket.service.PublicRecordAccessService; -import gg.modl.backend.ticket.service.PublicRecordAccessService.Access; -import gg.modl.backend.ticket.service.PublicRecordAccessService.AccessResult; -import gg.modl.backend.ticket.service.PublicRecordVerificationService; -import gg.modl.proto.modl.v1.CreatePublicAppealResponse; -import gg.modl.proto.modl.v1.PanelResource; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_APPEALS) -@RequiredArgsConstructor -public class PublicAppealController { - private final AppealService appealService; - private final PublicRecordAccessService recordAccessService; - private final PublicRecordVerificationService recordVerificationService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping("/{id}") - public ResponseEntity getAppeal( - @PathVariable String id, - @RequestParam(value = "token", required = false) String appealToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, appeal, appealToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); - } - - TicketResponse appealResponse = appealService.toResponse(appeal); - return ResponseEntity.ok(PublicAppealProtoMapper.toPublicAppealResponse(appealResponse)); - } - - @PostMapping - public ResponseEntity createAppeal( - @RequestBody gg.modl.proto.modl.v1.CreateAppealRequest createRequest, - @RequestParam(value = "token", required = false) String appealToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - TicketResponse appeal = appealService.createAppeal(server, PanelAppealProtoMapper.fromCreateAppealRequest(createRequest), appealToken); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, appeal.id()); - return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toCreateAppealResponse(appeal)); - } - - @PostMapping("/{id}/replies") - public ResponseEntity addReply( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, - @RequestParam(value = "token", required = false) String appealToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, appeal, appealToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); - } - - List attachments = PanelAppealProtoMapper.valueListToObjects(replyRequest.getAttachmentsList()); - TicketReply reply = appealService.addPublicReply(server, id, replyRequest.getContent(), attachments); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); - return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toAddReplyResponse(reply)); - } - - @PostMapping("/{id}/request-verification") - public ResponseEntity requestVerification(@PathVariable String id, HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - - Ticket appeal = appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).orElse(null); - if (appeal == null) { - return ResponseEntity.notFound().build(); - } - - String emailHint = recordVerificationService.sendVerificationCode(server, appeal); - return ResponseEntity.ok(PublicVerificationProtoMapper.toRequestVerificationResponse(emailHint)); - } - - @PostMapping("/{id}/verify") - public ResponseEntity verifyCode( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.VerifyTicketCodeRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).isEmpty()) { - return ResponseEntity.notFound().build(); - } - - String token = recordVerificationService.verifyCode(server, id, body.getCode()); - return ResponseEntity.ok(PublicVerificationProtoMapper.toVerifyResponse(token)); - } -} +package gg.modl.backend.appeal.controller; + +import gg.modl.backend.appeal.service.AppealService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.controller.PublicVerificationProtoMapper; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.response.TicketResponse; +import gg.modl.backend.ticket.service.PublicRecordAccessService; +import gg.modl.backend.ticket.service.PublicRecordAccessService.Access; +import gg.modl.backend.ticket.service.PublicRecordAccessService.AccessResult; +import gg.modl.backend.ticket.service.PublicRecordVerificationService; +import gg.modl.proto.modl.v1.CreatePublicAppealResponse; +import gg.modl.proto.modl.v1.PanelResource; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_APPEALS) +@RequiredArgsConstructor +public class PublicAppealController { + private final AppealService appealService; + private final PublicRecordAccessService recordAccessService; + private final PublicRecordVerificationService recordVerificationService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping("/{id}") + public ResponseEntity getAppeal( + @PathVariable String id, + @RequestParam(value = "token", required = false) String appealToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, appeal, appealToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); + } + + TicketResponse appealResponse = appealService.toResponse(appeal); + return ResponseEntity.ok(PublicAppealProtoMapper.toPublicAppealResponse(appealResponse)); + } + + @PostMapping + public ResponseEntity createAppeal( + @RequestBody gg.modl.proto.modl.v1.CreateAppealRequest createRequest, + @RequestParam(value = "token", required = false) String appealToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + TicketResponse appeal = appealService.createAppeal(server, PanelAppealProtoMapper.fromCreateAppealRequest(createRequest), appealToken); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, appeal.id()); + return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toCreateAppealResponse(appeal)); + } + + @PostMapping("/{id}/replies") + public ResponseEntity addReply( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, + @RequestParam(value = "token", required = false) String appealToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket appeal = appealService.getAppealRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, appeal, appealToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); + } + + List attachments = PanelAppealProtoMapper.valueListToObjects(replyRequest.getAttachmentsList()); + TicketReply reply = appealService.addPublicReply(server, id, replyRequest.getContent(), attachments); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_APPEALS, id); + return ResponseEntity.status(HttpStatus.CREATED).body(PublicAppealProtoMapper.toAddReplyResponse(reply)); + } + + @PostMapping("/{id}/request-verification") + public ResponseEntity requestVerification(@PathVariable String id, HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + + Ticket appeal = appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).orElse(null); + if (appeal == null) { + return ResponseEntity.notFound().build(); + } + + String emailHint = recordVerificationService.sendVerificationCode(server, appeal); + return ResponseEntity.ok(PublicVerificationProtoMapper.toRequestVerificationResponse(emailHint)); + } + + @PostMapping("/{id}/verify") + public ResponseEntity verifyCode( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.VerifyTicketCodeRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + if (appealService.getAppealRaw(server, id).filter(a -> !a.isHidden()).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String token = recordVerificationService.verifyCode(server, id, body.getCode()); + return ResponseEntity.ok(PublicVerificationProtoMapper.toVerifyResponse(token)); + } +} diff --git a/src/main/java/gg/modl/backend/audit/controller/AuditController.java b/src/main/java/gg/modl/backend/audit/controller/AuditController.java index c6ce232..c3fa5c1 100644 --- a/src/main/java/gg/modl/backend/audit/controller/AuditController.java +++ b/src/main/java/gg/modl/backend/audit/controller/AuditController.java @@ -1,235 +1,235 @@ -package gg.modl.backend.audit.controller; - -import gg.modl.backend.audit.service.AuditService; -import gg.modl.backend.audit.service.StaffPerformanceService; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; -import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; -import gg.modl.backend.audit.dto.response.StaffDetailsResponse; -import gg.modl.backend.audit.dto.response.StaffPerformanceResponse; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.ActivePunishmentsAuditResponse; -import gg.modl.proto.modl.v1.AuditBulkOperationResponse; -import gg.modl.proto.modl.v1.AuditDatabaseTableResponse; -import gg.modl.proto.modl.v1.AuditRollbackResponse; -import gg.modl.proto.modl.v1.BulkPunishmentActionRequest; -import gg.modl.proto.modl.v1.DateRangeRollbackRequest; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PunishmentAuditListResponse; -import gg.modl.proto.modl.v1.RollbackRequest; -import gg.modl.proto.modl.v1.StaffPerformanceListResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.Date; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_AUDIT) -@RequiredArgsConstructor -@Validated -public class AuditController { - private final AuditService auditService; - private final StaffPerformanceService staffPerformanceService; - private final PermissionService permissionService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping("/staff-performance") - public ResponseEntity getStaffPerformance( - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List performance = staffPerformanceService.getStaffPerformance(server, period); - return ResponseEntity.ok(AuditProtoMapper.toStaffPerformanceList(performance)); - } - - @GetMapping("/staff/{username}/details") - public ResponseEntity getStaffDetails( - @PathVariable String username, - @RequestParam(defaultValue = "30d") String period, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - StaffDetailsResponse details = staffPerformanceService.getStaffDetails(server, username, period); - return ResponseEntity.ok(AuditProtoMapper.toStaffDetails(details)); - } - - @GetMapping("/punishments/active") - public ResponseEntity getActivePunishments( - @RequestParam(defaultValue = "active") String status, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = auditService.getPunishmentsList(server, status); - return ResponseEntity.ok(AuditProtoMapper.toActivePunishments(punishments)); - } - - @GetMapping("/punishments") - public ResponseEntity getPunishments( - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(defaultValue = "false") boolean canRollback, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = auditService.getPunishments(server, limit, canRollback); - return ResponseEntity.ok(AuditProtoMapper.toPunishmentAuditList(punishments)); - } - - @PostMapping("/punishments/{id}/rollback") - public ResponseEntity rollbackPunishment( - @PathVariable String id, - @RequestBody(required = false) RollbackRequest rollbackRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String performerUsername = RequestUtil.getCurrentUsername(request); - - String reason = rollbackRequest != null && rollbackRequest.hasReason() - ? rollbackRequest.getReason() : "Admin rollback"; - boolean success = auditService.rollbackPunishment(server, id, reason, performerUsername); - - if (success) { - invalidateAudit(server); - return ResponseEntity.ok( - AuditProtoMapper.toRollbackResponse(true, "Punishment rolled back successfully")); - } - return ResponseEntity.notFound().build(); - } - - @PostMapping("/staff/{username}/rollback-all") - public ResponseEntity rollbackAllByStaff( - @PathVariable String username, - @RequestBody(required = false) RollbackRequest rollbackRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String performerUsername = RequestUtil.getCurrentUsername(request); - - String reason = rollbackRequest != null && rollbackRequest.hasReason() - ? rollbackRequest.getReason() : "Bulk rollback by admin"; - int count = auditService.rollbackAllPunishmentsByStaff(server, username, reason, performerUsername); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully rolled back " + count + " punishments")); - } - - @PostMapping("/staff/{username}/rollback-date-range") - public ResponseEntity rollbackByDateRange( - @PathVariable String username, - @RequestBody DateRangeRollbackRequest rollbackRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String performerUsername = RequestUtil.getCurrentUsername(request); - - Date startDate = AuditProtoMapper.toDate(rollbackRequest.getStartDate()); - Date endDate = AuditProtoMapper.toDate(rollbackRequest.getEndDate()); - if (startDate == null || endDate == null) { - throw new ValidationException("Start date and end date are required"); - } - - String reason = rollbackRequest.hasReason() ? rollbackRequest.getReason() : "Bulk rollback by admin"; - int count = auditService.rollbackPunishmentsByDateRange( - server, - username, - startDate, - endDate, - reason, - performerUsername - ); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully rolled back " + count + " punishments")); - } - - @PostMapping("/punishments/bulk-pardon") - public ResponseEntity bulkPardon( - @RequestBody BulkPunishmentActionRequest actionRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - - String performerUsername = RequestUtil.getCurrentUsername(request); - int count = auditService.bulkPardonByType( - server, actionRequest.getTypeOrdinalsList(), actionRequest.getReason(), performerUsername); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully pardoned " + count + " punishments")); - } - - @PostMapping("/punishments/bulk-set-expiration") - public ResponseEntity bulkSetExpiration( - @RequestBody BulkPunishmentActionRequest actionRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - - if (!actionRequest.hasNewDurationMs()) { - throw new ValidationException("newDurationMs is required for set-expiration"); - } - - String performerUsername = RequestUtil.getCurrentUsername(request); - int count = auditService.bulkSetExpirationByType( - server, actionRequest.getTypeOrdinalsList(), actionRequest.getNewDurationMs(), - actionRequest.getReason(), performerUsername); - - invalidateAudit(server); - return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( - true, count, "Successfully updated expiration for " + count + " punishments")); - } - - private void requireSuperAdmin(Server server, HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (!permissionService.isSuperAdmin(server, email)) { - throw new ForbiddenException("Only super admins can perform this action"); - } - } - - private void invalidateAudit(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_AUDIT); - } - - @GetMapping("/database/{table}") - public ResponseEntity getDatabaseTable( - @PathVariable String table, - @RequestParam(defaultValue = "100") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(defaultValue = "0") @Min(0) int skip, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - - if (!AuditService.ALLOWED_TABLES.contains(table)) { - throw new ValidationException("Invalid table name"); - } - - Map result = auditService.getDatabaseTable(server, table, limit, skip); - return ResponseEntity.ok(AuditProtoMapper.toDatabaseTableResponse(result)); - } -} +package gg.modl.backend.audit.controller; + +import gg.modl.backend.audit.service.AuditService; +import gg.modl.backend.audit.service.StaffPerformanceService; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; +import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; +import gg.modl.backend.audit.dto.response.StaffDetailsResponse; +import gg.modl.backend.audit.dto.response.StaffPerformanceResponse; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.ActivePunishmentsAuditResponse; +import gg.modl.proto.modl.v1.AuditBulkOperationResponse; +import gg.modl.proto.modl.v1.AuditDatabaseTableResponse; +import gg.modl.proto.modl.v1.AuditRollbackResponse; +import gg.modl.proto.modl.v1.BulkPunishmentActionRequest; +import gg.modl.proto.modl.v1.DateRangeRollbackRequest; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PunishmentAuditListResponse; +import gg.modl.proto.modl.v1.RollbackRequest; +import gg.modl.proto.modl.v1.StaffPerformanceListResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.Date; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_AUDIT) +@RequiredArgsConstructor +@Validated +public class AuditController { + private final AuditService auditService; + private final StaffPerformanceService staffPerformanceService; + private final PermissionService permissionService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping("/staff-performance") + public ResponseEntity getStaffPerformance( + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List performance = staffPerformanceService.getStaffPerformance(server, period); + return ResponseEntity.ok(AuditProtoMapper.toStaffPerformanceList(performance)); + } + + @GetMapping("/staff/{username}/details") + public ResponseEntity getStaffDetails( + @PathVariable String username, + @RequestParam(defaultValue = "30d") String period, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + StaffDetailsResponse details = staffPerformanceService.getStaffDetails(server, username, period); + return ResponseEntity.ok(AuditProtoMapper.toStaffDetails(details)); + } + + @GetMapping("/punishments/active") + public ResponseEntity getActivePunishments( + @RequestParam(defaultValue = "active") String status, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = auditService.getPunishmentsList(server, status); + return ResponseEntity.ok(AuditProtoMapper.toActivePunishments(punishments)); + } + + @GetMapping("/punishments") + public ResponseEntity getPunishments( + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(defaultValue = "false") boolean canRollback, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = auditService.getPunishments(server, limit, canRollback); + return ResponseEntity.ok(AuditProtoMapper.toPunishmentAuditList(punishments)); + } + + @PostMapping("/punishments/{id}/rollback") + public ResponseEntity rollbackPunishment( + @PathVariable String id, + @RequestBody(required = false) RollbackRequest rollbackRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String performerUsername = RequestUtil.getCurrentUsername(request); + + String reason = rollbackRequest != null && rollbackRequest.hasReason() + ? rollbackRequest.getReason() : "Admin rollback"; + boolean success = auditService.rollbackPunishment(server, id, reason, performerUsername); + + if (success) { + invalidateAudit(server); + return ResponseEntity.ok( + AuditProtoMapper.toRollbackResponse(true, "Punishment rolled back successfully")); + } + return ResponseEntity.notFound().build(); + } + + @PostMapping("/staff/{username}/rollback-all") + public ResponseEntity rollbackAllByStaff( + @PathVariable String username, + @RequestBody(required = false) RollbackRequest rollbackRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String performerUsername = RequestUtil.getCurrentUsername(request); + + String reason = rollbackRequest != null && rollbackRequest.hasReason() + ? rollbackRequest.getReason() : "Bulk rollback by admin"; + int count = auditService.rollbackAllPunishmentsByStaff(server, username, reason, performerUsername); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully rolled back " + count + " punishments")); + } + + @PostMapping("/staff/{username}/rollback-date-range") + public ResponseEntity rollbackByDateRange( + @PathVariable String username, + @RequestBody DateRangeRollbackRequest rollbackRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String performerUsername = RequestUtil.getCurrentUsername(request); + + Date startDate = AuditProtoMapper.toDate(rollbackRequest.getStartDate()); + Date endDate = AuditProtoMapper.toDate(rollbackRequest.getEndDate()); + if (startDate == null || endDate == null) { + throw new ValidationException("Start date and end date are required"); + } + + String reason = rollbackRequest.hasReason() ? rollbackRequest.getReason() : "Bulk rollback by admin"; + int count = auditService.rollbackPunishmentsByDateRange( + server, + username, + startDate, + endDate, + reason, + performerUsername + ); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully rolled back " + count + " punishments")); + } + + @PostMapping("/punishments/bulk-pardon") + public ResponseEntity bulkPardon( + @RequestBody BulkPunishmentActionRequest actionRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + + String performerUsername = RequestUtil.getCurrentUsername(request); + int count = auditService.bulkPardonByType( + server, actionRequest.getTypeOrdinalsList(), actionRequest.getReason(), performerUsername); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully pardoned " + count + " punishments")); + } + + @PostMapping("/punishments/bulk-set-expiration") + public ResponseEntity bulkSetExpiration( + @RequestBody BulkPunishmentActionRequest actionRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + + if (!actionRequest.hasNewDurationMs()) { + throw new ValidationException("newDurationMs is required for set-expiration"); + } + + String performerUsername = RequestUtil.getCurrentUsername(request); + int count = auditService.bulkSetExpirationByType( + server, actionRequest.getTypeOrdinalsList(), actionRequest.getNewDurationMs(), + actionRequest.getReason(), performerUsername); + + invalidateAudit(server); + return ResponseEntity.ok(AuditProtoMapper.toBulkOperationResponse( + true, count, "Successfully updated expiration for " + count + " punishments")); + } + + private void requireSuperAdmin(Server server, HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (!permissionService.isSuperAdmin(server, email)) { + throw new ForbiddenException("Only super admins can perform this action"); + } + } + + private void invalidateAudit(Server server) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_AUDIT); + } + + @GetMapping("/database/{table}") + public ResponseEntity getDatabaseTable( + @PathVariable String table, + @RequestParam(defaultValue = "100") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(defaultValue = "0") @Min(0) int skip, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + + if (!AuditService.ALLOWED_TABLES.contains(table)) { + throw new ValidationException("Invalid table name"); + } + + Map result = auditService.getDatabaseTable(server, table, limit, skip); + return ResponseEntity.ok(AuditProtoMapper.toDatabaseTableResponse(result)); + } +} diff --git a/src/main/java/gg/modl/backend/audit/data/AuditLog.java b/src/main/java/gg/modl/backend/audit/data/AuditLog.java index 760394b..6d52d27 100644 --- a/src/main/java/gg/modl/backend/audit/data/AuditLog.java +++ b/src/main/java/gg/modl/backend/audit/data/AuditLog.java @@ -1,44 +1,44 @@ -package gg.modl.backend.audit.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.Date; -import java.util.HashMap; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "METADATA_ROLLED_BACK", path = "metadata.rolledBack"), - @MongoFieldAlias(name = "METADATA_ROLLBACK_DATE", path = "metadata.rollbackDate"), - @MongoFieldAlias(name = "METADATA_ROLLBACK_BY", path = "metadata.rollbackBy"), - @MongoFieldAlias(name = "METADATA_CAN_ROLLBACK", path = "metadata.canRollback") -}) -public class AuditLog { - @Id - private String id; - - @Field("created") - private Date created; - @Field("level") - private String level; - @Field("source") - private String source; - @Field("description") - private String description; - @Field("metadata") - @Builder.Default - private Map metadata = new HashMap<>(); -} +package gg.modl.backend.audit.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "METADATA_ROLLED_BACK", path = "metadata.rolledBack"), + @MongoFieldAlias(name = "METADATA_ROLLBACK_DATE", path = "metadata.rollbackDate"), + @MongoFieldAlias(name = "METADATA_ROLLBACK_BY", path = "metadata.rollbackBy"), + @MongoFieldAlias(name = "METADATA_CAN_ROLLBACK", path = "metadata.canRollback") +}) +public class AuditLog { + @Id + private String id; + + @Field("created") + private Date created; + @Field("level") + private String level; + @Field("source") + private String source; + @Field("description") + private String description; + @Field("metadata") + @Builder.Default + private Map metadata = new HashMap<>(); +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java index 66692ef..9a8dbe0 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/ActivePunishmentResponse.java @@ -1,31 +1,31 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; -import java.util.List; - -public record ActivePunishmentResponse( - String id, - String playerId, - String playerName, - String type, - int typeOrdinal, - String category, - String staffName, - String reason, - Long duration, - Date issued, - Date started, - Date expires, - boolean active, - boolean hasEvidence, - int evidenceCount, - List evidence, - List attachedTicketIds -) { - public record EvidenceItem( - String text, - String url, - String type, - String fileName - ) {} -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; +import java.util.List; + +public record ActivePunishmentResponse( + String id, + String playerId, + String playerName, + String type, + int typeOrdinal, + String category, + String staffName, + String reason, + Long duration, + Date issued, + Date started, + Date expires, + boolean active, + boolean hasEvidence, + int evidenceCount, + List evidence, + List attachedTicketIds +) { + public record EvidenceItem( + String text, + String url, + String type, + String fileName + ) {} +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java index f305bcf..cf2b9e0 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/PunishmentAuditResponse.java @@ -1,17 +1,17 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; - -public record PunishmentAuditResponse( - String id, - String type, - String playerId, - String playerName, - String staffId, - String staffName, - String reason, - String duration, - Date timestamp, - boolean canRollback -) { -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; + +public record PunishmentAuditResponse( + String id, + String type, + String playerId, + String playerName, + String staffId, + String staffName, + String reason, + String duration, + Date timestamp, + boolean canRollback +) { +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java index ac413f7..4104985 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/StaffDetailsResponse.java @@ -1,55 +1,55 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; -import java.util.List; - -public record StaffDetailsResponse( - String username, - String period, - List punishments, - List tickets, - List dailyActivity, - List punishmentTypeBreakdown, - int evidenceUploads, - Summary summary -) { - public record PunishmentDetail( - String id, - String playerId, - String playerName, - String type, - String reason, - String duration, - Date issued, - boolean active, - boolean rolledBack - ) {} - - public record TicketDetail( - String id, - String subject, - String category, - String status, - Date lastActivity, - int responseTime - ) {} - - public record DailyActivity( - String date, - int punishments, - int tickets, - int evidence - ) {} - - public record PunishmentTypeBreakdown( - String type, - int count - ) {} - - public record Summary( - int totalPunishments, - int totalTickets, - int avgResponseTime, - int evidenceUploads - ) {} -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; +import java.util.List; + +public record StaffDetailsResponse( + String username, + String period, + List punishments, + List tickets, + List dailyActivity, + List punishmentTypeBreakdown, + int evidenceUploads, + Summary summary +) { + public record PunishmentDetail( + String id, + String playerId, + String playerName, + String type, + String reason, + String duration, + Date issued, + boolean active, + boolean rolledBack + ) {} + + public record TicketDetail( + String id, + String subject, + String category, + String status, + Date lastActivity, + int responseTime + ) {} + + public record DailyActivity( + String date, + int punishments, + int tickets, + int evidence + ) {} + + public record PunishmentTypeBreakdown( + String type, + int count + ) {} + + public record Summary( + int totalPunishments, + int totalTickets, + int avgResponseTime, + int evidenceUploads + ) {} +} diff --git a/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java b/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java index a7c8716..f756633 100644 --- a/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java +++ b/src/main/java/gg/modl/backend/audit/dto/response/StaffPerformanceResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.audit.dto.response; - -import java.util.Date; - -public record StaffPerformanceResponse( - String id, - String username, - String role, - int totalActions, - int ticketResponses, - int punishmentsIssued, - int avgResponseTime, - Date lastActive -) { -} +package gg.modl.backend.audit.dto.response; + +import java.util.Date; + +public record StaffPerformanceResponse( + String id, + String username, + String role, + int totalActions, + int ticketResponses, + int punishmentsIssued, + int avgResponseTime, + Date lastActive +) { +} diff --git a/src/main/java/gg/modl/backend/audit/service/AuditService.java b/src/main/java/gg/modl/backend/audit/service/AuditService.java index bf7427e..7c8e294 100644 --- a/src/main/java/gg/modl/backend/audit/service/AuditService.java +++ b/src/main/java/gg/modl/backend/audit/service/AuditService.java @@ -1,675 +1,675 @@ -package gg.modl.backend.audit.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.audit.data.AuditLog; -import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; -import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.service.PlayerStatusCalculator; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.player.service.PunishmentMutationService; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeIndex; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.dto.response.StaffResponse; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.infrastructure.util.IdGenerator; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.bson.Document; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AuditService { - - private final AuditMongoRepository auditRepository; - private final PunishmentTypeService punishmentTypeService; - private final StaffService staffService; - private final PlayerStatusCalculator statusCalculator; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentMutationService punishmentMutationService; - - private final Cache> activePunishmentsCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(60)) - .maximumSize(500) - .build(); - - public static final Set ALLOWED_TABLES = Set.of( - CollectionName.PLAYERS, - CollectionName.SETTINGS, - CollectionName.STAFF, - CollectionName.STAFF_ROLES, - CollectionName.TICKETS, - CollectionName.TICKET_VERIFICATIONS, - CollectionName.LOGS, - CollectionName.KNOWLEDGEBASE_CATEGORIES, - CollectionName.KNOWLEDGEBASE_ARTICLES, - CollectionName.HOMEPAGE_CARDS - ); - - private static final Set SAFE_SETTINGS_TYPES = Set.of( - "general", "punishmentTypes", "quickResponses", "replayRetention", - "statusThresholds", "ticketForms", "ticketLabels"); - private static final List SECRET_FIELD_NAMES = List.of( - "api_key", "ticket_api_key", "minecraft_api_key", "apiKey", "webhookUrl", "token", "secret", "password"); - private static final String REDACTED = "[REDACTED]"; - private static final long PERMANENT_PUNISHMENT_DURATION = -1L; - - public List getPunishments( - Server server, int limit, boolean canRollbackOnly) { - Date thirtyDaysAgo = DateRangeUtil.getStartDate("30d"); - List logs = - auditRepository.findPunishmentLogs(server, thirtyDaysAgo, limit, canRollbackOnly); - - return logs.stream().map(logEntry -> { - Map metadata = logEntry.getMetadata() != null - ? logEntry.getMetadata() : Collections.emptyMap(); - return new PunishmentAuditResponse( - logEntry.getId(), - extractPunishmentType(logEntry.getDescription()), - getStringFromMetadata(metadata, "playerId", "unknown"), - getStringFromMetadata(metadata, "playerName", "Unknown"), - getStringFromMetadata(metadata, "staffId", logEntry.getSource()), - logEntry.getSource(), - getStringFromMetadata(metadata, "reason", - logEntry.getDescription()), - getStringFromMetadata(metadata, "duration", null), - logEntry.getCreated(), - !Boolean.FALSE.equals(metadata.get("canRollback")) - ); - }).toList(); - } - - private String extractPunishmentType(String description) { - if (description == null) { - return "Unknown"; - } - String lower = description.toLowerCase(); - if (lower.contains("ban")) { - return "Ban"; - } - if (lower.contains("mute")) { - return "Mute"; - } - if (lower.contains("kick")) { - return "Kick"; - } - if (lower.contains("warn")) { - return "Warn"; - } - return "Unknown"; - } - - private String getStringFromMetadata( - Map metadata, String key, String defaultValue) { - Object value = metadata.get(key); - if (value instanceof String stringValue) { - return stringValue; - } - return defaultValue; - } - - public List getActivePunishments(Server server) { - return getPunishmentsList(server, "active"); - } - - public List getPunishmentsList(Server server, String statusFilter) { - List all = - activePunishmentsCache.get(server.getId(), key -> computeAllPunishments(server)); - - boolean filterActive = "active".equalsIgnoreCase(statusFilter); - boolean filterInactive = "inactive".equalsIgnoreCase(statusFilter); - - List results = new ArrayList<>(); - for (ActivePunishmentResponse punishment : all) { - if (filterActive && !punishment.active()) { - continue; - } - if (filterInactive && punishment.active()) { - continue; - } - results.add(punishment); - } - return results; - } - - private List computeAllPunishments(Server server) { - List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); - List rows = auditRepository.aggregatePunishmentRows(server); - Map resolvedIssuers = resolveIssuerNames(server, rows); - - List results = new ArrayList<>(); - for (Document row : rows) { - Punishment punishment = reconstructPunishment(row); - boolean active = statusCalculator.isPunishmentActive(punishment); - results.add(mapToActivePunishmentResponse( - server, row, punishment, active, typesByOrdinal, resolvedIssuers)); - } - return results; - } - - private ActivePunishmentResponse mapToActivePunishmentResponse( - Server server, Document row, Punishment punishment, boolean active, - Map typesByOrdinal, Map resolvedIssuers) { - int typeOrdinal = row.getInteger("typeOrdinal", 0); - String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); - PunishmentType matchedType = typesByOrdinal.get(typeOrdinal); - String category = matchedType != null - ? (matchedType.getCategory() != null ? matchedType.getCategory() : "Administrative") - : "Administrative"; - - Document data = row.get("data", Document.class); - String reason = data != null ? data.getString("reason") : null; - Long duration = extractDuration(data); - List evidenceItems = extractEvidenceItems(row); - - List ticketIds = row.getList("attachedTicketIds", String.class); - if (ticketIds == null) { - ticketIds = Collections.emptyList(); - } - - return new ActivePunishmentResponse( - row.getString("punishmentId"), - row.getString("playerId"), - AuditDocumentUtil.extractPlayerNameFromDoc(row), - typeName, - typeOrdinal, - category, - resolveIssuerFromDoc( - row.getString("issuerId"), - row.getString("issuerName"), - resolvedIssuers), - reason, - duration, - row.getDate("issued"), - row.getDate("started"), - statusCalculator.getEffectiveExpiry(punishment), - active, - !evidenceItems.isEmpty(), - evidenceItems.size(), - evidenceItems, - ticketIds - ); - } - - private List extractEvidenceItems(Document row) { - List evidenceDocs = row.getList("evidence", Document.class); - if (evidenceDocs == null) { - return Collections.emptyList(); - } - - List items = new ArrayList<>(); - for (Document evidenceDoc : evidenceDocs) { - items.add(new ActivePunishmentResponse.EvidenceItem( - evidenceDoc.getString("text"), - evidenceDoc.getString("url"), - evidenceDoc.getString("type"), - evidenceDoc.getString("fileName") - )); - } - return items; - } - - private static String resolveIssuerFromDoc( - String issuerId, String issuerName, Map resolvedIssuers) { - if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { - return resolvedIssuers.get(issuerId); - } - if (issuerName != null) { - return issuerName; - } - return issuerId != null ? "Unknown Staff" : "Console"; - } - - private Long extractDuration(Document data) { - if (data == null) { - return null; - } - Object durationObj = data.get("duration"); - if (durationObj instanceof Number number) { - return number.longValue(); - } - return null; - } - - private Map resolveIssuerNames(Server server, List rows) { - Set issuerIds = new HashSet<>(); - for (Document doc : rows) { - String issuerId = doc.getString("issuerId"); - if (issuerId != null) { - issuerIds.add(issuerId); - } - } - return auditRepository.mapStaffUsernamesByIds(server, issuerIds); - } - - private Punishment reconstructPunishment(Document doc) { - Punishment punishment = new Punishment(); - String reconstructedId = doc.getString("punishmentId"); - if (reconstructedId == null) { - reconstructedId = doc.getString("id"); - } - punishment.setId(reconstructedId); - punishment.setTypeOrdinal(doc.getInteger("typeOrdinal", 0)); - punishment.setIssuerName( - doc.getString("issuerName") != null ? doc.getString("issuerName") : "Unknown"); - punishment.setIssuerId(doc.getString("issuerId")); - punishment.setIssued( - doc.getDate("issued") != null ? doc.getDate("issued") : new Date()); - punishment.setStarted(doc.getDate("started")); - - Document data = doc.get("data", Document.class); - if (data != null) { - punishment.setData(new HashMap<>(data)); - } - - punishment.setModifications(extractModifications(doc)); - punishment.setNotes(Collections.emptyList()); - punishment.setEvidence(Collections.emptyList()); - punishment.setAttachedTicketIds(Collections.emptyList()); - - return punishment; - } - - private List extractModifications(Document doc) { - List modDocs = doc.getList("modifications", Document.class); - if (modDocs == null) { - return new ArrayList<>(); - } - - List mods = new ArrayList<>(); - for (Document modDoc : modDocs) { - Long effectiveDuration = null; - Object edObj = modDoc.get("effectiveDuration"); - if (edObj instanceof Number num) { - effectiveDuration = num.longValue(); - } - mods.add(new PunishmentModification( - modDoc.getString("id"), - modDoc.getString("type"), - modDoc.getDate("date"), - modDoc.getString("issuerName"), - modDoc.getString("issuerId"), - modDoc.getString("reason"), - effectiveDuration, - modDoc.getString("appealTicketId"), - null - )); - } - return mods; - } - - public boolean rollbackPunishment( - Server server, String punishmentId, String reason, String performerUsername) { - Document player = auditRepository.findPlayerByPunishmentId(server, punishmentId); - if (player == null) { - return false; - } - Document punishment = findPunishmentSubdocument(player, punishmentId); - if (punishment == null) { - return false; - } - if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { - throw new ValidationException("This punishment cannot be rolled back"); - } - - Date now = new Date(); - String playerId = player.getString("_id"); - auditRepository.appendPunishmentModification( - server, playerId, punishmentId, buildRollbackModification(performerUsername, reason, now)); - saveRollbackAuditLog( - server, playerId, - AuditDocumentUtil.extractPlayerNameFromDoc(player), punishment, - reason, performerUsername, now, - false, Objects.toString(punishment.getString("issuerName"), "")); - return true; - } - - private Map buildRollbackModification(String performerUsername, String reason, Date now) { - Map modification = new HashMap<>(); - modification.put("id", IdGenerator.generateShortId()); - modification.put("type", PunishmentModificationType.ROLLBACK.name()); - modification.put("date", now); - modification.put("issuerName", performerUsername); - modification.put("reason", reason != null ? reason : "Rollback"); - return modification; - } - - private Document findPunishmentSubdocument(Document player, String punishmentId) { - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - return null; - } - for (Document punishment : punishments) { - if (punishmentId.equals(punishment.getString("id"))) { - return punishment; - } - } - return null; - } - - private void saveRollbackAuditLog( - Server server, String playerId, String playerName, Document punishment, - String reason, String performerUsername, Date now, boolean bulk, String issuerUsername) { - int typeOrdinal = punishment.getInteger("typeOrdinal", 0); - String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); - String punishmentId = punishment.getString("id"); - - String description = bulk - ? "Bulk rollback: " + typeName + " for " + playerName + " (issued by " + issuerUsername + ")" - : "Rolled back " + typeName + " for " + (playerName.isEmpty() ? "unknown player" : playerName); - - AuditLog rollbackLog = AuditLog.builder() - .created(now) - .level("moderation") - .source(performerUsername) - .description(description) - .metadata(Map.of( - "punishmentId", punishmentId != null ? punishmentId : "", - "playerId", playerId != null ? playerId : "", - "playerName", playerName, - "staffUsername", issuerUsername, - "rollbackReason", reason != null ? reason : (bulk ? "Bulk rollback" : "Admin rollback"), - "punishmentType", typeName, - "bulkRollback", bulk - )) - .build(); - - auditRepository.saveAuditLog(server, rollbackLog); - } - - public Map getDatabaseTable( - Server server, String table, int limit, int skip) { - if (!ALLOWED_TABLES.contains(table)) { - throw new ValidationException("Invalid table name"); - } - - List documents = auditRepository.readTable(server, table, limit, skip); - long total = auditRepository.countCollection(server, table); - - return Map.of( - "data", redactDocuments(table, documents), - "total", total, - "limit", limit, - "skip", skip - ); - } - - private List redactDocuments(String table, List docs) { - if (docs == null) { - return Collections.emptyList(); - } - List redacted = new ArrayList<>(docs.size()); - for (Document orig : docs) { - Document copy = (Document) redactSecretFields(orig); - if (CollectionName.SETTINGS.equals(table) && !SAFE_SETTINGS_TYPES.contains(copy.getString("type"))) { - copy.put("data", REDACTED); - } - redacted.add(copy); - } - return redacted; - } - - private Object redactSecretFields(Object value) { - if (value instanceof Document document) { - Document copy = new Document(); - for (Map.Entry entry : document.entrySet()) { - copy.put(entry.getKey(), - isSecretFieldName(entry.getKey()) ? REDACTED : redactSecretFields(entry.getValue())); - } - return copy; - } - if (value instanceof List list) { - List copy = new ArrayList<>(list.size()); - for (Object element : list) { - copy.add(redactSecretFields(element)); - } - return copy; - } - return value; - } - - private boolean isSecretFieldName(String key) { - String lowerKey = key.toLowerCase(); - return SECRET_FIELD_NAMES.stream().anyMatch(secret -> lowerKey.contains(secret.toLowerCase())); - } - - public int rollbackAllPunishmentsByStaff( - Server server, String staffUsername, String reason, String performerUsername) { - String staffId = staffService.getStaffByUsername(server, staffUsername) - .map(StaffResponse::id) - .orElse(null); - return rollbackPunishmentsInternal( - server, staffUsername, staffId, null, null, reason, performerUsername); - } - - private int rollbackPunishmentsInternal( - Server server, String staffUsername, String staffId, - Date startDate, Date endDate, String reason, String performerUsername) { - try { - List players = - auditRepository.findPlayersForRollback(server, staffUsername, staffId); - Date now = new Date(); - - int rollbackCount = 0; - for (Document player : players) { - rollbackCount += applyRollbackToPlayer( - server, player, staffUsername, staffId, - startDate, endDate, reason, performerUsername, now); - } - return rollbackCount; - } catch (Exception e) { - log.error("Error during bulk rollback for staff {}", staffUsername, e); - throw new ExternalServiceException("Failed to rollback punishments", e); - } - } - - private int applyRollbackToPlayer( - Server server, Document player, String staffUsername, String staffId, - Date startDate, Date endDate, String reason, String performerUsername, Date now) { - String playerId = player.getString("_id"); - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - return 0; - } - - String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); - int count = 0; - - for (Document punishment : punishments) { - if (!matchesIssuer(punishment, staffUsername, staffId)) { - continue; - } - if (!isWithinDateRange(punishment.getDate("issued"), startDate, endDate)) { - continue; - } - if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { - continue; - } - - auditRepository.appendPunishmentModification( - server, playerId, punishment.getString("id"), - buildRollbackModification(performerUsername, reason, now)); - saveRollbackAuditLog( - server, playerId, playerName, punishment, - reason, performerUsername, now, true, staffUsername); - count++; - } - - return count; - } - - private boolean matchesIssuer( - Document punishment, String staffUsername, String staffId) { - String issuerName = punishment.getString("issuerName"); - String issuerId = punishment.getString("issuerId"); - return (issuerName != null && issuerName.equalsIgnoreCase(staffUsername)) - || (staffId != null && staffId.equals(issuerId)); - } - - private boolean isWithinDateRange(Date issued, Date startDate, Date endDate) { - if (startDate == null || endDate == null) { - return true; - } - return issued != null && !issued.before(startDate) && !issued.after(endDate); - } - - public int rollbackPunishmentsByDateRange( - Server server, String staffUsername, Date startDate, Date endDate, - String reason, String performerUsername) { - String staffId = staffService.getStaffByUsername(server, staffUsername) - .map(StaffResponse::id) - .orElse(null); - return rollbackPunishmentsInternal( - server, staffUsername, staffId, startDate, endDate, reason, performerUsername); - } - - public int bulkPardonByType( - Server server, List typeOrdinals, String reason, String performerUsername) { - return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, - "bulk pardon", (ctx) -> { - if (AuditDocumentUtil.hasModificationType(ctx.punishmentDoc, - PunishmentModificationType.MANUAL_PARDON.name(), - PunishmentModificationType.APPEAL_ACCEPT.name(), - PunishmentModificationType.SYSTEM_PARDON.name())) { - return false; - } - - PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( - server, ctx.punishmentId, performerUsername, null, reason); - if (!result.success()) { - return false; - } - - AuditLog pardonLog = buildBulkAuditLog(ctx, performerUsername, - "Bulk pardon: " + ctx.typeName + " for " + ctx.playerName, - Map.of("pardonReason", reason != null ? reason : "", "bulkPardon", true)); - auditRepository.saveAuditLog(server, pardonLog); - return true; - }); - } - - public int bulkSetExpirationByType( - Server server, List typeOrdinals, long newDurationMs, - String reason, String performerUsername) { - long effectiveDuration = newDurationMs <= 0 ? PERMANENT_PUNISHMENT_DURATION : newDurationMs; - - return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, - "bulk set expiration", (ctx) -> { - PunishmentOperationResult result = punishmentMutationService.changeDuration( - server, ctx.punishmentId, effectiveDuration, performerUsername, null); - if (!result.success()) { - return false; - } - - AuditLog durationLog = buildBulkAuditLog(ctx, performerUsername, - "Bulk duration change: " + ctx.typeName + " for " + ctx.playerName, - Map.of("reason", reason != null ? reason : "", "newDurationMs", newDurationMs, - "bulkDurationChange", true)); - auditRepository.saveAuditLog(server, durationLog); - return true; - }); - } - - private int processBulkPunishmentAction( - Server server, List typeOrdinals, String reason, - String performerUsername, String operationName, BulkPunishmentAction action) { - try { - List players = auditRepository.findPlayersForBulkAction(server, typeOrdinals); - Date now = new Date(); - int count = 0; - - Map typeNameCache = new HashMap<>(); - for (int ordinal : typeOrdinals) { - typeNameCache.put(ordinal, punishmentTypeService.getPunishmentTypeName(server, ordinal)); - } - - for (Document player : players) { - String playerId = player.getString("_id"); - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - continue; - } - - String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); - - for (Document punishmentDoc : punishments) { - int typeOrdinal = punishmentDoc.getInteger("typeOrdinal", 0); - if (!typeOrdinals.contains(typeOrdinal)) { - continue; - } - - Punishment punishment = reconstructPunishment(punishmentDoc); - if (!statusCalculator.isPunishmentActive(punishment)) { - continue; - } - - String punishmentId = punishmentDoc.getString("id"); - String typeName = typeNameCache.getOrDefault(typeOrdinal, "Unknown"); - - BulkActionContext ctx = new BulkActionContext( - playerId, playerName, punishmentId, punishmentDoc, typeName, typeOrdinal, now); - if (action.apply(ctx)) { - count++; - } - } - } - activePunishmentsCache.invalidate(server.getId()); - return count; - } catch (Exception e) { - log.error("Error during {}", operationName, e); - throw new ExternalServiceException("Failed to " + operationName, e); - } - } - - private AuditLog buildBulkAuditLog( - BulkActionContext ctx, String performerUsername, - String description, Map extraMetadata) { - Map metadata = new HashMap<>(); - metadata.put("punishmentId", ctx.punishmentId != null ? ctx.punishmentId : ""); - metadata.put("playerId", ctx.playerId != null ? ctx.playerId : ""); - metadata.put("playerName", ctx.playerName); - metadata.put("punishmentType", ctx.typeName); - metadata.putAll(extraMetadata); - - return AuditLog.builder() - .created(ctx.now) - .level("moderation") - .source(performerUsername) - .description(description) - .metadata(metadata) - .build(); - } - - private record BulkActionContext( - String playerId, String playerName, String punishmentId, - Document punishmentDoc, String typeName, int typeOrdinal, Date now) {} - - @FunctionalInterface - private interface BulkPunishmentAction { - boolean apply(BulkActionContext ctx); - } -} +package gg.modl.backend.audit.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.audit.data.AuditLog; +import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; +import gg.modl.backend.audit.dto.response.PunishmentAuditResponse; +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.repository.AuditMongoRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.service.PlayerStatusCalculator; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.player.service.PunishmentMutationService; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeIndex; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.dto.response.StaffResponse; +import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.infrastructure.util.IdGenerator; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuditService { + + private final AuditMongoRepository auditRepository; + private final PunishmentTypeService punishmentTypeService; + private final StaffService staffService; + private final PlayerStatusCalculator statusCalculator; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentMutationService punishmentMutationService; + + private final Cache> activePunishmentsCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(500) + .build(); + + public static final Set ALLOWED_TABLES = Set.of( + CollectionName.PLAYERS, + CollectionName.SETTINGS, + CollectionName.STAFF, + CollectionName.STAFF_ROLES, + CollectionName.TICKETS, + CollectionName.TICKET_VERIFICATIONS, + CollectionName.LOGS, + CollectionName.KNOWLEDGEBASE_CATEGORIES, + CollectionName.KNOWLEDGEBASE_ARTICLES, + CollectionName.HOMEPAGE_CARDS + ); + + private static final Set SAFE_SETTINGS_TYPES = Set.of( + "general", "punishmentTypes", "quickResponses", "replayRetention", + "statusThresholds", "ticketForms", "ticketLabels"); + private static final List SECRET_FIELD_NAMES = List.of( + "api_key", "ticket_api_key", "minecraft_api_key", "apiKey", "webhookUrl", "token", "secret", "password"); + private static final String REDACTED = "[REDACTED]"; + private static final long PERMANENT_PUNISHMENT_DURATION = -1L; + + public List getPunishments( + Server server, int limit, boolean canRollbackOnly) { + Date thirtyDaysAgo = DateRangeUtil.getStartDate("30d"); + List logs = + auditRepository.findPunishmentLogs(server, thirtyDaysAgo, limit, canRollbackOnly); + + return logs.stream().map(logEntry -> { + Map metadata = logEntry.getMetadata() != null + ? logEntry.getMetadata() : Collections.emptyMap(); + return new PunishmentAuditResponse( + logEntry.getId(), + extractPunishmentType(logEntry.getDescription()), + getStringFromMetadata(metadata, "playerId", "unknown"), + getStringFromMetadata(metadata, "playerName", "Unknown"), + getStringFromMetadata(metadata, "staffId", logEntry.getSource()), + logEntry.getSource(), + getStringFromMetadata(metadata, "reason", + logEntry.getDescription()), + getStringFromMetadata(metadata, "duration", null), + logEntry.getCreated(), + !Boolean.FALSE.equals(metadata.get("canRollback")) + ); + }).toList(); + } + + private String extractPunishmentType(String description) { + if (description == null) { + return "Unknown"; + } + String lower = description.toLowerCase(); + if (lower.contains("ban")) { + return "Ban"; + } + if (lower.contains("mute")) { + return "Mute"; + } + if (lower.contains("kick")) { + return "Kick"; + } + if (lower.contains("warn")) { + return "Warn"; + } + return "Unknown"; + } + + private String getStringFromMetadata( + Map metadata, String key, String defaultValue) { + Object value = metadata.get(key); + if (value instanceof String stringValue) { + return stringValue; + } + return defaultValue; + } + + public List getActivePunishments(Server server) { + return getPunishmentsList(server, "active"); + } + + public List getPunishmentsList(Server server, String statusFilter) { + List all = + activePunishmentsCache.get(server.getId(), key -> computeAllPunishments(server)); + + boolean filterActive = "active".equalsIgnoreCase(statusFilter); + boolean filterInactive = "inactive".equalsIgnoreCase(statusFilter); + + List results = new ArrayList<>(); + for (ActivePunishmentResponse punishment : all) { + if (filterActive && !punishment.active()) { + continue; + } + if (filterInactive && punishment.active()) { + continue; + } + results.add(punishment); + } + return results; + } + + private List computeAllPunishments(Server server) { + List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); + Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); + List rows = auditRepository.aggregatePunishmentRows(server); + Map resolvedIssuers = resolveIssuerNames(server, rows); + + List results = new ArrayList<>(); + for (Document row : rows) { + Punishment punishment = reconstructPunishment(row); + boolean active = statusCalculator.isPunishmentActive(punishment); + results.add(mapToActivePunishmentResponse( + server, row, punishment, active, typesByOrdinal, resolvedIssuers)); + } + return results; + } + + private ActivePunishmentResponse mapToActivePunishmentResponse( + Server server, Document row, Punishment punishment, boolean active, + Map typesByOrdinal, Map resolvedIssuers) { + int typeOrdinal = row.getInteger("typeOrdinal", 0); + String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); + PunishmentType matchedType = typesByOrdinal.get(typeOrdinal); + String category = matchedType != null + ? (matchedType.getCategory() != null ? matchedType.getCategory() : "Administrative") + : "Administrative"; + + Document data = row.get("data", Document.class); + String reason = data != null ? data.getString("reason") : null; + Long duration = extractDuration(data); + List evidenceItems = extractEvidenceItems(row); + + List ticketIds = row.getList("attachedTicketIds", String.class); + if (ticketIds == null) { + ticketIds = Collections.emptyList(); + } + + return new ActivePunishmentResponse( + row.getString("punishmentId"), + row.getString("playerId"), + AuditDocumentUtil.extractPlayerNameFromDoc(row), + typeName, + typeOrdinal, + category, + resolveIssuerFromDoc( + row.getString("issuerId"), + row.getString("issuerName"), + resolvedIssuers), + reason, + duration, + row.getDate("issued"), + row.getDate("started"), + statusCalculator.getEffectiveExpiry(punishment), + active, + !evidenceItems.isEmpty(), + evidenceItems.size(), + evidenceItems, + ticketIds + ); + } + + private List extractEvidenceItems(Document row) { + List evidenceDocs = row.getList("evidence", Document.class); + if (evidenceDocs == null) { + return Collections.emptyList(); + } + + List items = new ArrayList<>(); + for (Document evidenceDoc : evidenceDocs) { + items.add(new ActivePunishmentResponse.EvidenceItem( + evidenceDoc.getString("text"), + evidenceDoc.getString("url"), + evidenceDoc.getString("type"), + evidenceDoc.getString("fileName") + )); + } + return items; + } + + private static String resolveIssuerFromDoc( + String issuerId, String issuerName, Map resolvedIssuers) { + if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { + return resolvedIssuers.get(issuerId); + } + if (issuerName != null) { + return issuerName; + } + return issuerId != null ? "Unknown Staff" : "Console"; + } + + private Long extractDuration(Document data) { + if (data == null) { + return null; + } + Object durationObj = data.get("duration"); + if (durationObj instanceof Number number) { + return number.longValue(); + } + return null; + } + + private Map resolveIssuerNames(Server server, List rows) { + Set issuerIds = new HashSet<>(); + for (Document doc : rows) { + String issuerId = doc.getString("issuerId"); + if (issuerId != null) { + issuerIds.add(issuerId); + } + } + return auditRepository.mapStaffUsernamesByIds(server, issuerIds); + } + + private Punishment reconstructPunishment(Document doc) { + Punishment punishment = new Punishment(); + String reconstructedId = doc.getString("punishmentId"); + if (reconstructedId == null) { + reconstructedId = doc.getString("id"); + } + punishment.setId(reconstructedId); + punishment.setTypeOrdinal(doc.getInteger("typeOrdinal", 0)); + punishment.setIssuerName( + doc.getString("issuerName") != null ? doc.getString("issuerName") : "Unknown"); + punishment.setIssuerId(doc.getString("issuerId")); + punishment.setIssued( + doc.getDate("issued") != null ? doc.getDate("issued") : new Date()); + punishment.setStarted(doc.getDate("started")); + + Document data = doc.get("data", Document.class); + if (data != null) { + punishment.setData(new HashMap<>(data)); + } + + punishment.setModifications(extractModifications(doc)); + punishment.setNotes(Collections.emptyList()); + punishment.setEvidence(Collections.emptyList()); + punishment.setAttachedTicketIds(Collections.emptyList()); + + return punishment; + } + + private List extractModifications(Document doc) { + List modDocs = doc.getList("modifications", Document.class); + if (modDocs == null) { + return new ArrayList<>(); + } + + List mods = new ArrayList<>(); + for (Document modDoc : modDocs) { + Long effectiveDuration = null; + Object edObj = modDoc.get("effectiveDuration"); + if (edObj instanceof Number num) { + effectiveDuration = num.longValue(); + } + mods.add(new PunishmentModification( + modDoc.getString("id"), + modDoc.getString("type"), + modDoc.getDate("date"), + modDoc.getString("issuerName"), + modDoc.getString("issuerId"), + modDoc.getString("reason"), + effectiveDuration, + modDoc.getString("appealTicketId"), + null + )); + } + return mods; + } + + public boolean rollbackPunishment( + Server server, String punishmentId, String reason, String performerUsername) { + Document player = auditRepository.findPlayerByPunishmentId(server, punishmentId); + if (player == null) { + return false; + } + Document punishment = findPunishmentSubdocument(player, punishmentId); + if (punishment == null) { + return false; + } + if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { + throw new ValidationException("This punishment cannot be rolled back"); + } + + Date now = new Date(); + String playerId = player.getString("_id"); + auditRepository.appendPunishmentModification( + server, playerId, punishmentId, buildRollbackModification(performerUsername, reason, now)); + saveRollbackAuditLog( + server, playerId, + AuditDocumentUtil.extractPlayerNameFromDoc(player), punishment, + reason, performerUsername, now, + false, Objects.toString(punishment.getString("issuerName"), "")); + return true; + } + + private Map buildRollbackModification(String performerUsername, String reason, Date now) { + Map modification = new HashMap<>(); + modification.put("id", IdGenerator.generateShortId()); + modification.put("type", PunishmentModificationType.ROLLBACK.name()); + modification.put("date", now); + modification.put("issuerName", performerUsername); + modification.put("reason", reason != null ? reason : "Rollback"); + return modification; + } + + private Document findPunishmentSubdocument(Document player, String punishmentId) { + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + return null; + } + for (Document punishment : punishments) { + if (punishmentId.equals(punishment.getString("id"))) { + return punishment; + } + } + return null; + } + + private void saveRollbackAuditLog( + Server server, String playerId, String playerName, Document punishment, + String reason, String performerUsername, Date now, boolean bulk, String issuerUsername) { + int typeOrdinal = punishment.getInteger("typeOrdinal", 0); + String typeName = punishmentTypeService.getPunishmentTypeName(server, typeOrdinal); + String punishmentId = punishment.getString("id"); + + String description = bulk + ? "Bulk rollback: " + typeName + " for " + playerName + " (issued by " + issuerUsername + ")" + : "Rolled back " + typeName + " for " + (playerName.isEmpty() ? "unknown player" : playerName); + + AuditLog rollbackLog = AuditLog.builder() + .created(now) + .level("moderation") + .source(performerUsername) + .description(description) + .metadata(Map.of( + "punishmentId", punishmentId != null ? punishmentId : "", + "playerId", playerId != null ? playerId : "", + "playerName", playerName, + "staffUsername", issuerUsername, + "rollbackReason", reason != null ? reason : (bulk ? "Bulk rollback" : "Admin rollback"), + "punishmentType", typeName, + "bulkRollback", bulk + )) + .build(); + + auditRepository.saveAuditLog(server, rollbackLog); + } + + public Map getDatabaseTable( + Server server, String table, int limit, int skip) { + if (!ALLOWED_TABLES.contains(table)) { + throw new ValidationException("Invalid table name"); + } + + List documents = auditRepository.readTable(server, table, limit, skip); + long total = auditRepository.countCollection(server, table); + + return Map.of( + "data", redactDocuments(table, documents), + "total", total, + "limit", limit, + "skip", skip + ); + } + + private List redactDocuments(String table, List docs) { + if (docs == null) { + return Collections.emptyList(); + } + List redacted = new ArrayList<>(docs.size()); + for (Document orig : docs) { + Document copy = (Document) redactSecretFields(orig); + if (CollectionName.SETTINGS.equals(table) && !SAFE_SETTINGS_TYPES.contains(copy.getString("type"))) { + copy.put("data", REDACTED); + } + redacted.add(copy); + } + return redacted; + } + + private Object redactSecretFields(Object value) { + if (value instanceof Document document) { + Document copy = new Document(); + for (Map.Entry entry : document.entrySet()) { + copy.put(entry.getKey(), + isSecretFieldName(entry.getKey()) ? REDACTED : redactSecretFields(entry.getValue())); + } + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(list.size()); + for (Object element : list) { + copy.add(redactSecretFields(element)); + } + return copy; + } + return value; + } + + private boolean isSecretFieldName(String key) { + String lowerKey = key.toLowerCase(); + return SECRET_FIELD_NAMES.stream().anyMatch(secret -> lowerKey.contains(secret.toLowerCase())); + } + + public int rollbackAllPunishmentsByStaff( + Server server, String staffUsername, String reason, String performerUsername) { + String staffId = staffService.getStaffByUsername(server, staffUsername) + .map(StaffResponse::id) + .orElse(null); + return rollbackPunishmentsInternal( + server, staffUsername, staffId, null, null, reason, performerUsername); + } + + private int rollbackPunishmentsInternal( + Server server, String staffUsername, String staffId, + Date startDate, Date endDate, String reason, String performerUsername) { + try { + List players = + auditRepository.findPlayersForRollback(server, staffUsername, staffId); + Date now = new Date(); + + int rollbackCount = 0; + for (Document player : players) { + rollbackCount += applyRollbackToPlayer( + server, player, staffUsername, staffId, + startDate, endDate, reason, performerUsername, now); + } + return rollbackCount; + } catch (Exception e) { + log.error("Error during bulk rollback for staff {}", staffUsername, e); + throw new ExternalServiceException("Failed to rollback punishments", e); + } + } + + private int applyRollbackToPlayer( + Server server, Document player, String staffUsername, String staffId, + Date startDate, Date endDate, String reason, String performerUsername, Date now) { + String playerId = player.getString("_id"); + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + return 0; + } + + String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); + int count = 0; + + for (Document punishment : punishments) { + if (!matchesIssuer(punishment, staffUsername, staffId)) { + continue; + } + if (!isWithinDateRange(punishment.getDate("issued"), startDate, endDate)) { + continue; + } + if (AuditDocumentUtil.hasModificationType(punishment, PunishmentModificationType.ROLLBACK.name())) { + continue; + } + + auditRepository.appendPunishmentModification( + server, playerId, punishment.getString("id"), + buildRollbackModification(performerUsername, reason, now)); + saveRollbackAuditLog( + server, playerId, playerName, punishment, + reason, performerUsername, now, true, staffUsername); + count++; + } + + return count; + } + + private boolean matchesIssuer( + Document punishment, String staffUsername, String staffId) { + String issuerName = punishment.getString("issuerName"); + String issuerId = punishment.getString("issuerId"); + return (issuerName != null && issuerName.equalsIgnoreCase(staffUsername)) + || (staffId != null && staffId.equals(issuerId)); + } + + private boolean isWithinDateRange(Date issued, Date startDate, Date endDate) { + if (startDate == null || endDate == null) { + return true; + } + return issued != null && !issued.before(startDate) && !issued.after(endDate); + } + + public int rollbackPunishmentsByDateRange( + Server server, String staffUsername, Date startDate, Date endDate, + String reason, String performerUsername) { + String staffId = staffService.getStaffByUsername(server, staffUsername) + .map(StaffResponse::id) + .orElse(null); + return rollbackPunishmentsInternal( + server, staffUsername, staffId, startDate, endDate, reason, performerUsername); + } + + public int bulkPardonByType( + Server server, List typeOrdinals, String reason, String performerUsername) { + return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, + "bulk pardon", (ctx) -> { + if (AuditDocumentUtil.hasModificationType(ctx.punishmentDoc, + PunishmentModificationType.MANUAL_PARDON.name(), + PunishmentModificationType.APPEAL_ACCEPT.name(), + PunishmentModificationType.SYSTEM_PARDON.name())) { + return false; + } + + PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( + server, ctx.punishmentId, performerUsername, null, reason); + if (!result.success()) { + return false; + } + + AuditLog pardonLog = buildBulkAuditLog(ctx, performerUsername, + "Bulk pardon: " + ctx.typeName + " for " + ctx.playerName, + Map.of("pardonReason", reason != null ? reason : "", "bulkPardon", true)); + auditRepository.saveAuditLog(server, pardonLog); + return true; + }); + } + + public int bulkSetExpirationByType( + Server server, List typeOrdinals, long newDurationMs, + String reason, String performerUsername) { + long effectiveDuration = newDurationMs <= 0 ? PERMANENT_PUNISHMENT_DURATION : newDurationMs; + + return processBulkPunishmentAction(server, typeOrdinals, reason, performerUsername, + "bulk set expiration", (ctx) -> { + PunishmentOperationResult result = punishmentMutationService.changeDuration( + server, ctx.punishmentId, effectiveDuration, performerUsername, null); + if (!result.success()) { + return false; + } + + AuditLog durationLog = buildBulkAuditLog(ctx, performerUsername, + "Bulk duration change: " + ctx.typeName + " for " + ctx.playerName, + Map.of("reason", reason != null ? reason : "", "newDurationMs", newDurationMs, + "bulkDurationChange", true)); + auditRepository.saveAuditLog(server, durationLog); + return true; + }); + } + + private int processBulkPunishmentAction( + Server server, List typeOrdinals, String reason, + String performerUsername, String operationName, BulkPunishmentAction action) { + try { + List players = auditRepository.findPlayersForBulkAction(server, typeOrdinals); + Date now = new Date(); + int count = 0; + + Map typeNameCache = new HashMap<>(); + for (int ordinal : typeOrdinals) { + typeNameCache.put(ordinal, punishmentTypeService.getPunishmentTypeName(server, ordinal)); + } + + for (Document player : players) { + String playerId = player.getString("_id"); + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + continue; + } + + String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); + + for (Document punishmentDoc : punishments) { + int typeOrdinal = punishmentDoc.getInteger("typeOrdinal", 0); + if (!typeOrdinals.contains(typeOrdinal)) { + continue; + } + + Punishment punishment = reconstructPunishment(punishmentDoc); + if (!statusCalculator.isPunishmentActive(punishment)) { + continue; + } + + String punishmentId = punishmentDoc.getString("id"); + String typeName = typeNameCache.getOrDefault(typeOrdinal, "Unknown"); + + BulkActionContext ctx = new BulkActionContext( + playerId, playerName, punishmentId, punishmentDoc, typeName, typeOrdinal, now); + if (action.apply(ctx)) { + count++; + } + } + } + activePunishmentsCache.invalidate(server.getId()); + return count; + } catch (Exception e) { + log.error("Error during {}", operationName, e); + throw new ExternalServiceException("Failed to " + operationName, e); + } + } + + private AuditLog buildBulkAuditLog( + BulkActionContext ctx, String performerUsername, + String description, Map extraMetadata) { + Map metadata = new HashMap<>(); + metadata.put("punishmentId", ctx.punishmentId != null ? ctx.punishmentId : ""); + metadata.put("playerId", ctx.playerId != null ? ctx.playerId : ""); + metadata.put("playerName", ctx.playerName); + metadata.put("punishmentType", ctx.typeName); + metadata.putAll(extraMetadata); + + return AuditLog.builder() + .created(ctx.now) + .level("moderation") + .source(performerUsername) + .description(description) + .metadata(metadata) + .build(); + } + + private record BulkActionContext( + String playerId, String playerName, String punishmentId, + Document punishmentDoc, String typeName, int typeOrdinal, Date now) {} + + @FunctionalInterface + private interface BulkPunishmentAction { + boolean apply(BulkActionContext ctx); + } +} diff --git a/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java b/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java index 6ace3fd..b16d717 100644 --- a/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java +++ b/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java @@ -1,12 +1,12 @@ -package gg.modl.backend.auth; - -public final class AuthResponseMessage { - public static final String MISSING_EMAIL = "Valid email is required."; - public static final String UNAUTHORIZED_EMAIL = "This email is not authorized to access this panel."; - public static final String VERIFICATION_CODE_SENT = "Verification code sent to your email."; - public static final String MISSING_CODE = "Verification code is required."; - public static final String INVALID_CODE = "Invalid or expired verification code."; - public static final String LOGIN_SUCCESS = "Login successful."; - public static final String LOGOUT_SUCCESS = "Logged out successfully."; - public static final String EMAIL_SEND_ERROR = "Failed to send verification email. Please try again."; -} +package gg.modl.backend.auth; + +public final class AuthResponseMessage { + public static final String MISSING_EMAIL = "Valid email is required."; + public static final String UNAUTHORIZED_EMAIL = "This email is not authorized to access this panel."; + public static final String VERIFICATION_CODE_SENT = "Verification code sent to your email."; + public static final String MISSING_CODE = "Verification code is required."; + public static final String INVALID_CODE = "Invalid or expired verification code."; + public static final String LOGIN_SUCCESS = "Login successful."; + public static final String LOGOUT_SUCCESS = "Logged out successfully."; + public static final String EMAIL_SEND_ERROR = "Failed to send verification email. Please try again."; +} diff --git a/src/main/java/gg/modl/backend/auth/AuthService.java b/src/main/java/gg/modl/backend/auth/AuthService.java index 52f8bb4..defae4d 100644 --- a/src/main/java/gg/modl/backend/auth/AuthService.java +++ b/src/main/java/gg/modl/backend/auth/AuthService.java @@ -1,109 +1,109 @@ -package gg.modl.backend.auth; - -import gg.modl.backend.auth.data.AuthCode; -import gg.modl.backend.database.mongo.repository.AuthCodeMongoRepository; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.email.EmailHTMLTemplate; -import gg.modl.backend.email.EmailService; -import gg.modl.backend.infrastructure.onetimecode.OneTimeCodeCodec; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class AuthService { - private final EmailService emailService; - private final AuthCodeMongoRepository authCodeRepository; - private final AuthConfiguration authConfiguration; - private final OneTimeCodeCodec oneTimeCodeCodec; - - @Async("emailTaskExecutor") - public void sendUserLoginCode(Server server, String email) { - String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> - authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); - - if (code == null) { - return; - } - - emailService.send(email, EmailHTMLTemplate.USER_CODE.build(server.getServerName(), code)); - } - - @Async("emailTaskExecutor") - public void sendEmailChangeCode(Server server, String newEmail) { - String code = prepareAndStoreCode(newEmail, (normalizedEmail, codeHash, expiresAt) -> - authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); - - if (code == null) { - return; - } - - emailService.send(newEmail, EmailHTMLTemplate.EMAIL_CHANGE_CODE.build(server.getServerName(), code)); - } - - private String prepareAndStoreCode(String email, CodeStorageAction storageAction) { - String code = oneTimeCodeCodec.generateNumericCode(authConfiguration.getEmailCodeLength()); - String codeHash = hash(code); - String normalizedEmail = EmailAddressUtil.normalize(email); - Date expiresAt = new Date(System.currentTimeMillis() + (authConfiguration.getEmailCodeExpiry() * 1000L)); - - storageAction.store(normalizedEmail, codeHash, expiresAt); - - return code; - } - - private String hash(String code) { - return oneTimeCodeCodec.hash(code, authConfiguration.getCodeHashSecret()); - } - - @Async("emailTaskExecutor") - public void sendAdminLoginCode(String email) { - String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> - authCodeRepository.replaceForGlobal(normalizedEmail, codeHash, expiresAt)); - - if (code == null) { - return; - } - - emailService.send(email, EmailHTMLTemplate.ADMIN_CODE.build(null, code)); - } - - public boolean verifyCode(Server server, String email, String code) { - String normalizedEmail = EmailAddressUtil.normalize(email); - String codeHash = hash(code); - Date now = new Date(); - - Optional consumed = authCodeRepository.consumeIfHashMatchesForServer(server, normalizedEmail, codeHash, now); - if (consumed.isPresent()) { - return true; - } - - authCodeRepository.incrementFailedAttemptsForServer(server, normalizedEmail, now); - return false; - } - - public boolean verifyAdminCode(String email, String code) { - String normalizedEmail = EmailAddressUtil.normalize(email); - String codeHash = hash(code); - Date now = new Date(); - - Optional consumed = authCodeRepository.consumeIfHashMatchesForGlobal(normalizedEmail, codeHash, now); - if (consumed.isPresent()) { - return true; - } - - authCodeRepository.incrementFailedAttemptsForGlobal(normalizedEmail, now); - return false; - } - - @FunctionalInterface - private interface CodeStorageAction { - void store(String normalizedEmail, String codeHash, Date expiresAt); - } -} +package gg.modl.backend.auth; + +import gg.modl.backend.auth.data.AuthCode; +import gg.modl.backend.database.mongo.repository.AuthCodeMongoRepository; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.email.EmailHTMLTemplate; +import gg.modl.backend.email.EmailService; +import gg.modl.backend.infrastructure.onetimecode.OneTimeCodeCodec; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthService { + private final EmailService emailService; + private final AuthCodeMongoRepository authCodeRepository; + private final AuthConfiguration authConfiguration; + private final OneTimeCodeCodec oneTimeCodeCodec; + + @Async("emailTaskExecutor") + public void sendUserLoginCode(Server server, String email) { + String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> + authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); + + if (code == null) { + return; + } + + emailService.send(email, EmailHTMLTemplate.USER_CODE.build(server.getServerName(), code)); + } + + @Async("emailTaskExecutor") + public void sendEmailChangeCode(Server server, String newEmail) { + String code = prepareAndStoreCode(newEmail, (normalizedEmail, codeHash, expiresAt) -> + authCodeRepository.replaceForServer(server, normalizedEmail, codeHash, expiresAt)); + + if (code == null) { + return; + } + + emailService.send(newEmail, EmailHTMLTemplate.EMAIL_CHANGE_CODE.build(server.getServerName(), code)); + } + + private String prepareAndStoreCode(String email, CodeStorageAction storageAction) { + String code = oneTimeCodeCodec.generateNumericCode(authConfiguration.getEmailCodeLength()); + String codeHash = hash(code); + String normalizedEmail = EmailAddressUtil.normalize(email); + Date expiresAt = new Date(System.currentTimeMillis() + (authConfiguration.getEmailCodeExpiry() * 1000L)); + + storageAction.store(normalizedEmail, codeHash, expiresAt); + + return code; + } + + private String hash(String code) { + return oneTimeCodeCodec.hash(code, authConfiguration.getCodeHashSecret()); + } + + @Async("emailTaskExecutor") + public void sendAdminLoginCode(String email) { + String code = prepareAndStoreCode(email, (normalizedEmail, codeHash, expiresAt) -> + authCodeRepository.replaceForGlobal(normalizedEmail, codeHash, expiresAt)); + + if (code == null) { + return; + } + + emailService.send(email, EmailHTMLTemplate.ADMIN_CODE.build(null, code)); + } + + public boolean verifyCode(Server server, String email, String code) { + String normalizedEmail = EmailAddressUtil.normalize(email); + String codeHash = hash(code); + Date now = new Date(); + + Optional consumed = authCodeRepository.consumeIfHashMatchesForServer(server, normalizedEmail, codeHash, now); + if (consumed.isPresent()) { + return true; + } + + authCodeRepository.incrementFailedAttemptsForServer(server, normalizedEmail, now); + return false; + } + + public boolean verifyAdminCode(String email, String code) { + String normalizedEmail = EmailAddressUtil.normalize(email); + String codeHash = hash(code); + Date now = new Date(); + + Optional consumed = authCodeRepository.consumeIfHashMatchesForGlobal(normalizedEmail, codeHash, now); + if (consumed.isPresent()) { + return true; + } + + authCodeRepository.incrementFailedAttemptsForGlobal(normalizedEmail, now); + return false; + } + + @FunctionalInterface + private interface CodeStorageAction { + void store(String normalizedEmail, String codeHash, Date expiresAt); + } +} diff --git a/src/main/java/gg/modl/backend/auth/WebAuthnService.java b/src/main/java/gg/modl/backend/auth/WebAuthnService.java index de9127c..90a6224 100644 --- a/src/main/java/gg/modl/backend/auth/WebAuthnService.java +++ b/src/main/java/gg/modl/backend/auth/WebAuthnService.java @@ -1,404 +1,404 @@ -package gg.modl.backend.auth; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.yubico.webauthn.AssertionRequest; -import com.yubico.webauthn.AssertionResult; -import com.yubico.webauthn.CredentialRepository; -import com.yubico.webauthn.FinishAssertionOptions; -import com.yubico.webauthn.FinishRegistrationOptions; -import com.yubico.webauthn.RegisteredCredential; -import com.yubico.webauthn.RegistrationResult; -import com.yubico.webauthn.RelyingParty; -import com.yubico.webauthn.StartAssertionOptions; -import com.yubico.webauthn.StartRegistrationOptions; -import com.yubico.webauthn.data.AttestationConveyancePreference; -import com.yubico.webauthn.data.AuthenticatorAssertionResponse; -import com.yubico.webauthn.data.AuthenticatorAttestationResponse; -import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; -import com.yubico.webauthn.data.ByteArray; -import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; -import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; -import com.yubico.webauthn.data.PublicKeyCredential; -import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; -import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; -import com.yubico.webauthn.data.PublicKeyCredentialType; -import com.yubico.webauthn.data.RelyingPartyIdentity; -import com.yubico.webauthn.data.ResidentKeyRequirement; -import com.yubico.webauthn.data.UserIdentity; -import com.yubico.webauthn.data.UserVerificationRequirement; -import com.yubico.webauthn.data.exception.Base64UrlException; -import com.yubico.webauthn.exception.AssertionFailedException; -import com.yubico.webauthn.exception.RegistrationFailedException; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.auth.data.WebAuthnChallenge; -import gg.modl.backend.auth.data.WebAuthnCredential; -import gg.modl.backend.database.mongo.repository.WebAuthnChallengeMongoRepository; -import gg.modl.backend.database.mongo.repository.WebAuthnCredentialMongoRepository; -import gg.modl.backend.server.data.CustomDomainStatus; -import gg.modl.backend.server.data.Server; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Date; -import java.util.HashSet; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.function.Predicate; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class WebAuthnService { - private final WebAuthnChallengeMongoRepository challengeRepository; - private final WebAuthnCredentialMongoRepository credentialRepository; - private final AuthConfiguration authConfiguration; - - public StartRegistrationResult startRegistration(Server server, String email) { - RelyingParty rp = buildRelyingParty(server); - - UserIdentity userIdentity = UserIdentity.builder() - .name(email) - .displayName(email) - .id(userHandle(email)) - .build(); - - PublicKeyCredentialCreationOptions options = rp.startRegistration( - StartRegistrationOptions.builder() - .user(userIdentity) - .authenticatorSelection(AuthenticatorSelectionCriteria.builder() - .residentKey(ResidentKeyRequirement.PREFERRED) - .userVerification(UserVerificationRequirement.REQUIRED) - .build()) - .build() - ); - - String challengeId = UUID.randomUUID().toString(); - try { - WebAuthnChallenge challenge = new WebAuthnChallenge(); - challenge.setId(challengeId); - challenge.setChallengeJson(options.toJson()); - challenge.setEmail(normalizeEmail(email)); - challenge.setExpiresAt(challengeExpiry()); - challengeRepository.saveEntity(server, challenge); - return new StartRegistrationResult(challengeId, options.toCredentialsCreateJson()); - } catch (JsonProcessingException e) { - throw new ExternalServiceException("Failed to serialize registration options", e); - } - } - - private RelyingParty buildRelyingParty(Server server) { - String rpId = resolveRpId(server); - Set origins = resolveOrigins(server, rpId); - CredentialRepositoryAdapter credRepo = new CredentialRepositoryAdapter(server); - - return RelyingParty.builder() - .identity(RelyingPartyIdentity.builder() - .id(rpId) - .name("modl") - .build()) - .credentialRepository(credRepo) - .origins(origins) - .attestationConveyancePreference(AttestationConveyancePreference.NONE) - .build(); - } - - private String resolveRpId(Server server) { - if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() - && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { - return server.getCustomDomainOverride(); - } - return "modl.gg"; - } - - private Set resolveOrigins(Server server, String rpId) { - Set origins = new HashSet<>(); - if (authConfiguration.isDevelopmentMode()) { - origins.add("http://localhost:3000"); - origins.add("http://localhost:5173"); - } - if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() - && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { - origins.add("https://" + server.getCustomDomainOverride()); - } else { - origins.add("https://" + server.getCustomDomain() + ".modl.gg"); - } - origins.add("https://" + rpId); - return origins; - } - - private ByteArray userHandle(String email) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hash = digest.digest(normalizeEmail(email).getBytes(StandardCharsets.UTF_8)); - return new ByteArray(hash); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 not available", e); - } - } - - private String normalizeEmail(String email) { - return EmailAddressUtil.normalize(email); - } - - private Date challengeExpiry() { - return new Date(System.currentTimeMillis() + 5 * 60 * 1000); - } - - public void finishRegistration(Server server, String email, String challengeId, String responseJson, String credentialName) - throws Exception { - RelyingParty rp = buildRelyingParty(server); - WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); - if (challenge == null) { - throw new ResourceNotFoundException("Challenge not found or expired"); - } - - String normalizedEmail = normalizeEmail(email); - if (!normalizedEmail.equals(challenge.getEmail())) { - throw new ValidationException("Email mismatch"); - } - - PublicKeyCredentialCreationOptions options = PublicKeyCredentialCreationOptions.fromJson(challenge.getChallengeJson()); - PublicKeyCredential pkc = - PublicKeyCredential.parseRegistrationResponseJson(responseJson); - - RegistrationResult result; - try { - result = rp.finishRegistration( - FinishRegistrationOptions.builder() - .request(options) - .response(pkc) - .build() - ); - } catch (RegistrationFailedException e) { - throw new ValidationException("Registration verification failed: " + e.getMessage(), e); - } - - WebAuthnCredential cred = new WebAuthnCredential(); - cred.setEmail(normalizedEmail); - cred.setUserHandle(userHandle(normalizedEmail).getBase64Url()); - cred.setCredentialId(result.getKeyId().getId().getBase64Url()); - cred.setPublicKeyCose(result.getPublicKeyCose().getBytes()); - cred.setSignatureCount(result.getSignatureCount()); - cred.setName(credentialName != null && !credentialName.isBlank() ? credentialName.trim() : "Passkey"); - cred.setCreatedAt(new Date()); - cred.setLastUsedAt(new Date()); - credentialRepository.saveEntity(server, cred); - } - - public boolean checkHasPasskeys(Server server, String email) { - return credentialRepository.existsByEmail(server, email); - } - - public StartAuthenticationResult startDiscoverableAuthentication(Server server) { - RelyingParty rp = buildRelyingParty(server); - AssertionRequest assertionRequest = rp.startAssertion( - StartAssertionOptions.builder() - .userVerification(UserVerificationRequirement.REQUIRED) - .build() - ); - - String challengeId = UUID.randomUUID().toString(); - try { - WebAuthnChallenge challenge = new WebAuthnChallenge(); - challenge.setId(challengeId); - challenge.setChallengeJson(assertionRequest.toJson()); - challenge.setEmail(null); - challenge.setExpiresAt(challengeExpiry()); - challengeRepository.saveEntity(server, challenge); - return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); - } catch (JsonProcessingException e) { - throw new ExternalServiceException("Failed to serialize assertion request", e); - } - } - - public StartAuthenticationResult startAuthentication(Server server, String email) { - RelyingParty rp = buildRelyingParty(server); - AssertionRequest assertionRequest = rp.startAssertion( - StartAssertionOptions.builder() - .username(normalizeEmail(email)) - .userVerification(UserVerificationRequirement.REQUIRED) - .build() - ); - - String challengeId = UUID.randomUUID().toString(); - try { - WebAuthnChallenge challenge = new WebAuthnChallenge(); - challenge.setId(challengeId); - challenge.setChallengeJson(assertionRequest.toJson()); - challenge.setEmail(normalizeEmail(email)); - challenge.setExpiresAt(challengeExpiry()); - challengeRepository.saveEntity(server, challenge); - return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); - } catch (JsonProcessingException e) { - throw new ExternalServiceException("Failed to serialize assertion request", e); - } - } - - public String finishAuthentication(Server server, String challengeId, String responseJson, Predicate isAuthorized) throws Exception { - RelyingParty rp = buildRelyingParty(server); - WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); - if (challenge == null) { - throw new ResourceNotFoundException("Challenge not found or expired"); - } - - AssertionRequest assertionRequest = AssertionRequest.fromJson(challenge.getChallengeJson()); - PublicKeyCredential pkc = - PublicKeyCredential.parseAssertionResponseJson(responseJson); - - AssertionResult result; - try { - result = rp.finishAssertion( - FinishAssertionOptions.builder() - .request(assertionRequest) - .response(pkc) - .build() - ); - } catch (AssertionFailedException e) { - throw new UnauthorizedException("Authentication verification failed: " + e.getMessage(), e); - } - - if (!result.isSuccess()) { - throw new UnauthorizedException("Authentication failed"); - } - - if (!result.isSignatureCounterValid()) { - log.warn("WebAuthn signature counter invalid for credential {}: possible cloned authenticator", - result.getCredential().getCredentialId().getBase64Url()); - throw new UnauthorizedException("Authentication failed: possible cloned authenticator"); - } - - String email = challenge.getEmail(); - if (email == null || email.isBlank()) { - ByteArray userHandle = result.getCredential().getUserHandle(); - if (userHandle == null) { - throw new ResourceNotFoundException("Could not determine user identity"); - } - WebAuthnCredential cred = credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).orElse(null); - if (cred == null) { - throw new ResourceNotFoundException("Could not determine user identity"); - } - email = cred.getEmail(); - } - - if (!isAuthorized.test(email)) { - throw new ValidationException("Not authorized"); - } - - String credentialId = result.getCredential().getCredentialId().getBase64Url(); - boolean updated = credentialRepository.updateUsage(server, credentialId, result.getSignatureCount(), new Date()); - if (!updated) { - throw new UnauthorizedException("Authentication failed: credential not found"); - } - - return email; - } - - public List listCredentials(Server server, String email) { - return credentialRepository.findByEmail(server, email) - .stream() - .map(c -> new CredentialInfo(c.getId(), c.getName(), c.getCreatedAt(), c.getLastUsedAt())) - .collect(Collectors.toList()); - } - - public boolean renameCredential(Server server, String email, String credentialMongoId, String newName) { - return credentialRepository.renameByIdAndEmail(server, credentialMongoId, email, newName); - } - - public boolean deleteCredential(Server server, String email, String credentialMongoId) { - return credentialRepository.deleteByIdAndEmail(server, credentialMongoId, email); - } - - public long deleteCredentialsForEmail(Server server, String email) { - return credentialRepository.deleteAllByEmail(server, email); - } - - public record StartRegistrationResult(String challengeId, String optionsJson) { - } - - public record StartAuthenticationResult(String challengeId, String optionsJson, boolean hasPasskeys) { - } - - public record CredentialInfo(String id, String name, Date createdAt, Date lastUsedAt) { - } - - private class CredentialRepositoryAdapter implements CredentialRepository { - private final Server server; - - CredentialRepositoryAdapter(Server server) { - this.server = server; - } - - @Override - public Set getCredentialIdsForUsername(String username) { - return credentialRepository.findByEmail(server, username) - .stream() - .map(c -> { - try { - return PublicKeyCredentialDescriptor.builder() - .id(ByteArray.fromBase64Url(c.getCredentialId())) - .type(PublicKeyCredentialType.PUBLIC_KEY) - .build(); - } catch (Base64UrlException e) { - log.error("Invalid base64url credential ID for {}", c.getId(), e); - return null; - } - }) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - @Override - public Optional getUserHandleForUsername(String username) { - return Optional.of(userHandle(username)); - } - - @Override - public Optional getUsernameForUserHandle(ByteArray userHandle) { - return credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).map(WebAuthnCredential::getEmail); - } - - @Override - public Optional lookup(ByteArray credentialId, ByteArray userHandle) { - return credentialRepository.findByCredentialId(server, credentialId.getBase64Url()) - .filter(cred -> { - try { - ByteArray storedHandle = ByteArray.fromBase64Url(cred.getUserHandle()); - return storedHandle.equals(userHandle); - } catch (Base64UrlException e) { - log.error("Invalid base64url user handle for credential {}", cred.getId(), e); - return false; - } - }) - .map(cred -> RegisteredCredential.builder() - .credentialId(credentialId) - .userHandle(userHandle) - .publicKeyCose(new ByteArray(cred.getPublicKeyCose())) - .signatureCount(cred.getSignatureCount()) - .build()); - } - - @Override - public Set lookupAll(ByteArray credentialId) { - return credentialRepository.findAllByCredentialId(server, credentialId.getBase64Url()) - .stream() - .map(c -> RegisteredCredential.builder() - .credentialId(credentialId) - .userHandle(userHandle(c.getEmail())) - .publicKeyCose(new ByteArray(c.getPublicKeyCose())) - .signatureCount(c.getSignatureCount()) - .build()) - .collect(Collectors.toSet()); - } - } -} +package gg.modl.backend.auth; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.yubico.webauthn.AssertionRequest; +import com.yubico.webauthn.AssertionResult; +import com.yubico.webauthn.CredentialRepository; +import com.yubico.webauthn.FinishAssertionOptions; +import com.yubico.webauthn.FinishRegistrationOptions; +import com.yubico.webauthn.RegisteredCredential; +import com.yubico.webauthn.RegistrationResult; +import com.yubico.webauthn.RelyingParty; +import com.yubico.webauthn.StartAssertionOptions; +import com.yubico.webauthn.StartRegistrationOptions; +import com.yubico.webauthn.data.AttestationConveyancePreference; +import com.yubico.webauthn.data.AuthenticatorAssertionResponse; +import com.yubico.webauthn.data.AuthenticatorAttestationResponse; +import com.yubico.webauthn.data.AuthenticatorSelectionCriteria; +import com.yubico.webauthn.data.ByteArray; +import com.yubico.webauthn.data.ClientAssertionExtensionOutputs; +import com.yubico.webauthn.data.ClientRegistrationExtensionOutputs; +import com.yubico.webauthn.data.PublicKeyCredential; +import com.yubico.webauthn.data.PublicKeyCredentialCreationOptions; +import com.yubico.webauthn.data.PublicKeyCredentialDescriptor; +import com.yubico.webauthn.data.PublicKeyCredentialType; +import com.yubico.webauthn.data.RelyingPartyIdentity; +import com.yubico.webauthn.data.ResidentKeyRequirement; +import com.yubico.webauthn.data.UserIdentity; +import com.yubico.webauthn.data.UserVerificationRequirement; +import com.yubico.webauthn.data.exception.Base64UrlException; +import com.yubico.webauthn.exception.AssertionFailedException; +import com.yubico.webauthn.exception.RegistrationFailedException; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.auth.data.WebAuthnChallenge; +import gg.modl.backend.auth.data.WebAuthnCredential; +import gg.modl.backend.database.mongo.repository.WebAuthnChallengeMongoRepository; +import gg.modl.backend.database.mongo.repository.WebAuthnCredentialMongoRepository; +import gg.modl.backend.server.data.CustomDomainStatus; +import gg.modl.backend.server.data.Server; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WebAuthnService { + private final WebAuthnChallengeMongoRepository challengeRepository; + private final WebAuthnCredentialMongoRepository credentialRepository; + private final AuthConfiguration authConfiguration; + + public StartRegistrationResult startRegistration(Server server, String email) { + RelyingParty rp = buildRelyingParty(server); + + UserIdentity userIdentity = UserIdentity.builder() + .name(email) + .displayName(email) + .id(userHandle(email)) + .build(); + + PublicKeyCredentialCreationOptions options = rp.startRegistration( + StartRegistrationOptions.builder() + .user(userIdentity) + .authenticatorSelection(AuthenticatorSelectionCriteria.builder() + .residentKey(ResidentKeyRequirement.PREFERRED) + .userVerification(UserVerificationRequirement.REQUIRED) + .build()) + .build() + ); + + String challengeId = UUID.randomUUID().toString(); + try { + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setId(challengeId); + challenge.setChallengeJson(options.toJson()); + challenge.setEmail(normalizeEmail(email)); + challenge.setExpiresAt(challengeExpiry()); + challengeRepository.saveEntity(server, challenge); + return new StartRegistrationResult(challengeId, options.toCredentialsCreateJson()); + } catch (JsonProcessingException e) { + throw new ExternalServiceException("Failed to serialize registration options", e); + } + } + + private RelyingParty buildRelyingParty(Server server) { + String rpId = resolveRpId(server); + Set origins = resolveOrigins(server, rpId); + CredentialRepositoryAdapter credRepo = new CredentialRepositoryAdapter(server); + + return RelyingParty.builder() + .identity(RelyingPartyIdentity.builder() + .id(rpId) + .name("modl") + .build()) + .credentialRepository(credRepo) + .origins(origins) + .attestationConveyancePreference(AttestationConveyancePreference.NONE) + .build(); + } + + private String resolveRpId(Server server) { + if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() + && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { + return server.getCustomDomainOverride(); + } + return "modl.gg"; + } + + private Set resolveOrigins(Server server, String rpId) { + Set origins = new HashSet<>(); + if (authConfiguration.isDevelopmentMode()) { + origins.add("http://localhost:3000"); + origins.add("http://localhost:5173"); + } + if (server.getCustomDomainOverride() != null && !server.getCustomDomainOverride().isBlank() + && server.getCustomDomainStatus() == CustomDomainStatus.ACTIVE) { + origins.add("https://" + server.getCustomDomainOverride()); + } else { + origins.add("https://" + server.getCustomDomain() + ".modl.gg"); + } + origins.add("https://" + rpId); + return origins; + } + + private ByteArray userHandle(String email) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(normalizeEmail(email).getBytes(StandardCharsets.UTF_8)); + return new ByteArray(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + + private String normalizeEmail(String email) { + return EmailAddressUtil.normalize(email); + } + + private Date challengeExpiry() { + return new Date(System.currentTimeMillis() + 5 * 60 * 1000); + } + + public void finishRegistration(Server server, String email, String challengeId, String responseJson, String credentialName) + throws Exception { + RelyingParty rp = buildRelyingParty(server); + WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); + if (challenge == null) { + throw new ResourceNotFoundException("Challenge not found or expired"); + } + + String normalizedEmail = normalizeEmail(email); + if (!normalizedEmail.equals(challenge.getEmail())) { + throw new ValidationException("Email mismatch"); + } + + PublicKeyCredentialCreationOptions options = PublicKeyCredentialCreationOptions.fromJson(challenge.getChallengeJson()); + PublicKeyCredential pkc = + PublicKeyCredential.parseRegistrationResponseJson(responseJson); + + RegistrationResult result; + try { + result = rp.finishRegistration( + FinishRegistrationOptions.builder() + .request(options) + .response(pkc) + .build() + ); + } catch (RegistrationFailedException e) { + throw new ValidationException("Registration verification failed: " + e.getMessage(), e); + } + + WebAuthnCredential cred = new WebAuthnCredential(); + cred.setEmail(normalizedEmail); + cred.setUserHandle(userHandle(normalizedEmail).getBase64Url()); + cred.setCredentialId(result.getKeyId().getId().getBase64Url()); + cred.setPublicKeyCose(result.getPublicKeyCose().getBytes()); + cred.setSignatureCount(result.getSignatureCount()); + cred.setName(credentialName != null && !credentialName.isBlank() ? credentialName.trim() : "Passkey"); + cred.setCreatedAt(new Date()); + cred.setLastUsedAt(new Date()); + credentialRepository.saveEntity(server, cred); + } + + public boolean checkHasPasskeys(Server server, String email) { + return credentialRepository.existsByEmail(server, email); + } + + public StartAuthenticationResult startDiscoverableAuthentication(Server server) { + RelyingParty rp = buildRelyingParty(server); + AssertionRequest assertionRequest = rp.startAssertion( + StartAssertionOptions.builder() + .userVerification(UserVerificationRequirement.REQUIRED) + .build() + ); + + String challengeId = UUID.randomUUID().toString(); + try { + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setId(challengeId); + challenge.setChallengeJson(assertionRequest.toJson()); + challenge.setEmail(null); + challenge.setExpiresAt(challengeExpiry()); + challengeRepository.saveEntity(server, challenge); + return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); + } catch (JsonProcessingException e) { + throw new ExternalServiceException("Failed to serialize assertion request", e); + } + } + + public StartAuthenticationResult startAuthentication(Server server, String email) { + RelyingParty rp = buildRelyingParty(server); + AssertionRequest assertionRequest = rp.startAssertion( + StartAssertionOptions.builder() + .username(normalizeEmail(email)) + .userVerification(UserVerificationRequirement.REQUIRED) + .build() + ); + + String challengeId = UUID.randomUUID().toString(); + try { + WebAuthnChallenge challenge = new WebAuthnChallenge(); + challenge.setId(challengeId); + challenge.setChallengeJson(assertionRequest.toJson()); + challenge.setEmail(normalizeEmail(email)); + challenge.setExpiresAt(challengeExpiry()); + challengeRepository.saveEntity(server, challenge); + return new StartAuthenticationResult(challengeId, assertionRequest.toCredentialsGetJson(), true); + } catch (JsonProcessingException e) { + throw new ExternalServiceException("Failed to serialize assertion request", e); + } + } + + public String finishAuthentication(Server server, String challengeId, String responseJson, Predicate isAuthorized) throws Exception { + RelyingParty rp = buildRelyingParty(server); + WebAuthnChallenge challenge = challengeRepository.consumeActiveChallenge(server, challengeId, new Date()).orElse(null); + if (challenge == null) { + throw new ResourceNotFoundException("Challenge not found or expired"); + } + + AssertionRequest assertionRequest = AssertionRequest.fromJson(challenge.getChallengeJson()); + PublicKeyCredential pkc = + PublicKeyCredential.parseAssertionResponseJson(responseJson); + + AssertionResult result; + try { + result = rp.finishAssertion( + FinishAssertionOptions.builder() + .request(assertionRequest) + .response(pkc) + .build() + ); + } catch (AssertionFailedException e) { + throw new UnauthorizedException("Authentication verification failed: " + e.getMessage(), e); + } + + if (!result.isSuccess()) { + throw new UnauthorizedException("Authentication failed"); + } + + if (!result.isSignatureCounterValid()) { + log.warn("WebAuthn signature counter invalid for credential {}: possible cloned authenticator", + result.getCredential().getCredentialId().getBase64Url()); + throw new UnauthorizedException("Authentication failed: possible cloned authenticator"); + } + + String email = challenge.getEmail(); + if (email == null || email.isBlank()) { + ByteArray userHandle = result.getCredential().getUserHandle(); + if (userHandle == null) { + throw new ResourceNotFoundException("Could not determine user identity"); + } + WebAuthnCredential cred = credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).orElse(null); + if (cred == null) { + throw new ResourceNotFoundException("Could not determine user identity"); + } + email = cred.getEmail(); + } + + if (!isAuthorized.test(email)) { + throw new ValidationException("Not authorized"); + } + + String credentialId = result.getCredential().getCredentialId().getBase64Url(); + boolean updated = credentialRepository.updateUsage(server, credentialId, result.getSignatureCount(), new Date()); + if (!updated) { + throw new UnauthorizedException("Authentication failed: credential not found"); + } + + return email; + } + + public List listCredentials(Server server, String email) { + return credentialRepository.findByEmail(server, email) + .stream() + .map(c -> new CredentialInfo(c.getId(), c.getName(), c.getCreatedAt(), c.getLastUsedAt())) + .collect(Collectors.toList()); + } + + public boolean renameCredential(Server server, String email, String credentialMongoId, String newName) { + return credentialRepository.renameByIdAndEmail(server, credentialMongoId, email, newName); + } + + public boolean deleteCredential(Server server, String email, String credentialMongoId) { + return credentialRepository.deleteByIdAndEmail(server, credentialMongoId, email); + } + + public long deleteCredentialsForEmail(Server server, String email) { + return credentialRepository.deleteAllByEmail(server, email); + } + + public record StartRegistrationResult(String challengeId, String optionsJson) { + } + + public record StartAuthenticationResult(String challengeId, String optionsJson, boolean hasPasskeys) { + } + + public record CredentialInfo(String id, String name, Date createdAt, Date lastUsedAt) { + } + + private class CredentialRepositoryAdapter implements CredentialRepository { + private final Server server; + + CredentialRepositoryAdapter(Server server) { + this.server = server; + } + + @Override + public Set getCredentialIdsForUsername(String username) { + return credentialRepository.findByEmail(server, username) + .stream() + .map(c -> { + try { + return PublicKeyCredentialDescriptor.builder() + .id(ByteArray.fromBase64Url(c.getCredentialId())) + .type(PublicKeyCredentialType.PUBLIC_KEY) + .build(); + } catch (Base64UrlException e) { + log.error("Invalid base64url credential ID for {}", c.getId(), e); + return null; + } + }) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + @Override + public Optional getUserHandleForUsername(String username) { + return Optional.of(userHandle(username)); + } + + @Override + public Optional getUsernameForUserHandle(ByteArray userHandle) { + return credentialRepository.findByUserHandle(server, userHandle.getBase64Url()).map(WebAuthnCredential::getEmail); + } + + @Override + public Optional lookup(ByteArray credentialId, ByteArray userHandle) { + return credentialRepository.findByCredentialId(server, credentialId.getBase64Url()) + .filter(cred -> { + try { + ByteArray storedHandle = ByteArray.fromBase64Url(cred.getUserHandle()); + return storedHandle.equals(userHandle); + } catch (Base64UrlException e) { + log.error("Invalid base64url user handle for credential {}", cred.getId(), e); + return false; + } + }) + .map(cred -> RegisteredCredential.builder() + .credentialId(credentialId) + .userHandle(userHandle) + .publicKeyCose(new ByteArray(cred.getPublicKeyCose())) + .signatureCount(cred.getSignatureCount()) + .build()); + } + + @Override + public Set lookupAll(ByteArray credentialId) { + return credentialRepository.findAllByCredentialId(server, credentialId.getBase64Url()) + .stream() + .map(c -> RegisteredCredential.builder() + .credentialId(credentialId) + .userHandle(userHandle(c.getEmail())) + .publicKeyCose(new ByteArray(c.getPublicKeyCose())) + .signatureCount(c.getSignatureCount()) + .build()) + .collect(Collectors.toSet()); + } + } +} diff --git a/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java b/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java index 064699b..5100fdd 100644 --- a/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java +++ b/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java @@ -1,328 +1,328 @@ -package gg.modl.backend.auth.controller; - -import gg.modl.backend.auth.AuthConfiguration; -import gg.modl.backend.auth.AuthResponseMessage; -import gg.modl.backend.auth.AuthService; -import gg.modl.backend.auth.EmailChangeService; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.proto.modl.v1.PanelAuthResponse; -import gg.modl.proto.modl.v1.PanelPermissionsResponse; -import gg.modl.proto.modl.v1.PanelProfileResponse; -import gg.modl.proto.modl.v1.PanelSendEmailCodeRequest; -import gg.modl.proto.modl.v1.PanelSessionsResponse; -import gg.modl.proto.modl.v1.PanelUpdateEmailRequest; -import gg.modl.proto.modl.v1.PanelUpdateEmailWithCodeRequest; -import gg.modl.proto.modl.v1.PanelUpdateProfileRequest; -import gg.modl.proto.modl.v1.PanelVerifyEmailCodeRequest; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_AUTH) -@RequiredArgsConstructor -@Slf4j -public class PanelAuthController { - private final AuthService authService; - private final SessionService sessionService; - private final AuthConfiguration authConfiguration; - private final StaffService staffService; - private final PermissionService permissionService; - private final CookieUtil cookieUtil; - private final EmailChangeService emailChangeService; - - @PostMapping("/send-email-code") - public ResponseEntity sendEmailCode( - HttpServletRequest request, - @RequestBody PanelSendEmailCodeRequest requestData) throws Exception { - - Server server = RequestUtil.getRequestServer(request); - - // Always return generic success to prevent email enumeration - if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); - } - - authService.sendUserLoginCode(server, requestData.getEmail()); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); - } - - @PostMapping("/verify-email-code") - public ResponseEntity verifyEmailCode( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody PanelVerifyEmailCodeRequest requestData) { - - Server server = RequestUtil.getRequestServer(request); - - // Return same error as invalid code to prevent email enumeration - if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { - return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); - } - - boolean valid = authService.verifyCode(server, requestData.getEmail(), requestData.getCode()); - - if (!valid) { - return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); - } - - String clientIp = RequestUtil.getClientIp(request); - String userAgent = request.getHeader("User-Agent"); - AuthSessionData session = sessionService.createSession(server, requestData.getEmail(), clientIp, userAgent); - - response.addCookie(cookieUtil.createSessionCookie(session.getId())); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGIN_SUCCESS)); - } - - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { - Server server = RequestUtil.getRequestServer(request); - Set sessionIds = extractSessionIds(request); - - for (String sessionId : sessionIds) { - sessionService.invalidateSession(server, sessionId); - } - - expireSessionCookies(response); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); - } - - private Set extractSessionIds(HttpServletRequest request) { - Cookie[] cookies = request.getCookies(); - if (cookies == null) { - return Set.of(); - } - - return Arrays.stream(cookies) - .filter(cookie -> authConfiguration.getSessionCookieName().equals(cookie.getName())) - .map(Cookie::getValue) - .filter(value -> value != null && !value.isBlank()) - .collect(Collectors.toCollection(LinkedHashSet::new)); - } - - @PatchMapping("/profile") - public ResponseEntity updateProfile( - HttpServletRequest request, - @RequestBody PanelUpdateProfileRequest requestData) { - - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); - - String username = requestData.hasUsername() ? requestData.getUsername() : null; - String language = requestData.hasLanguage() ? requestData.getLanguage() : null; - String dateFormat = requestData.hasDateFormat() ? requestData.getDateFormat() : null; - - Optional result = staffService.updateOrCreateProfileUsername(server, email, username, isSuperAdmin, language, dateFormat); - if (result.isEmpty()) { - if (isSuperAdmin) { - String resolvedUsername = username != null ? username : "Admin"; - String resolvedLanguage = language != null ? language : "en"; - String resolvedDateFormat = dateFormat != null ? dateFormat : "MM/DD/YYYY"; - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - null, email, resolvedUsername, "Super Admin", resolvedUsername, resolvedLanguage, resolvedDateFormat)); - } - return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); - } - Staff staff = result.get(); - String role = isSuperAdmin ? "Super Admin" : permissionService.resolveRoleName(server, staff.getRoleId()); - String minecraftUsername = staff.getAssignedMinecraftUsername() != null - ? staff.getAssignedMinecraftUsername() - : staff.getUsername(); - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); - } - - @PostMapping("/email/send-code") - public ResponseEntity sendEmailChangeCode( - HttpServletRequest request, - @RequestBody PanelUpdateEmailRequest requestData) { - - String currentEmail = RequestUtil.getSessionEmail(request); - if (currentEmail == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - emailChangeService.sendChangeCode(server, currentEmail, requestData.getNewEmail()); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Verification code sent to new email.")); - } - - @PatchMapping("/email") - public ResponseEntity updateEmail( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody PanelUpdateEmailWithCodeRequest requestData) { - - String currentEmail = RequestUtil.getSessionEmail(request); - if (currentEmail == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - AuthSessionData newSession = emailChangeService.changeEmail( - server, currentEmail, requestData.getNewEmail(), requestData.getCode(), - RequestUtil.getClientIp(request), request.getHeader("User-Agent")); - response.addCookie(cookieUtil.createSessionCookie(newSession.getId())); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Email updated successfully.")); - } - - @GetMapping("/me") - public ResponseEntity getCurrentUser(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); - - Optional staffOpt = staffService.getStaffByEmail(server, email); - - if (staffOpt.isPresent()) { - Staff staff = staffOpt.get(); - String role = isSuperAdmin ? "Super Admin" : permissionService.resolveRoleName(server, staff.getRoleId()); - // Include Minecraft username if assigned, fall back to panel username - String minecraftUsername = staff.getAssignedMinecraftUsername() != null - ? staff.getAssignedMinecraftUsername() - : staff.getUsername(); - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); - } - - // Super Admin without a staff record - return default username - if (isSuperAdmin) { - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse(null, email, "Admin", "Super Admin", "Admin", "en", "MM/DD/YYYY")); - } - - return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); - } - - @GetMapping("/sessions") - public ResponseEntity getSessions(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - AuthSessionData currentSession = RequestUtil.getSession(request); - String currentSessionId = currentSession != null ? currentSession.getId() : null; - - List sessions = sessionService.findAllSessionsForEmail(server, email); - - return ResponseEntity.ok(PanelAuthProtoMapper.toSessionsResponse(sessions, currentSessionId)); - } - - @DeleteMapping("/sessions/{publicId}") - public ResponseEntity revokeSession( - HttpServletRequest request, - HttpServletResponse response, - @PathVariable String publicId) { - - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - Optional target = sessionService.findSessionByPublicId(server, email, publicId); - if (target.isEmpty()) { - return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Session not found")); - } - - String sessionId = target.get().getId(); - sessionService.invalidateSession(server, sessionId); - expireCookiesIfCurrentSession(request, response, sessionId); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); - } - - @DeleteMapping("/sessions") - public ResponseEntity revokeAllSessions(HttpServletRequest request, HttpServletResponse response) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); - } - - Server server = RequestUtil.getRequestServer(request); - sessionService.invalidateAllSessionsForEmail(server, email); - expireSessionCookies(response); - - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); - } - - private void expireCookiesIfCurrentSession(HttpServletRequest request, HttpServletResponse response, String sessionId) { - AuthSessionData currentSession = RequestUtil.getSession(request); - if (currentSession != null && sessionId.equals(currentSession.getId())) { - expireSessionCookies(response); - } - } - - private void expireSessionCookies(HttpServletResponse response) { - for (Cookie cookie : cookieUtil.createExpiredSessionCookies()) { - response.addCookie(cookie); - } - } - - @GetMapping("/permissions") - public ResponseEntity getUserPermissions(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - return ResponseEntity.status(401).body(PanelAuthProtoMapper.toPermissionsResponse(List.of())); - } - - Server server = RequestUtil.getRequestServer(request); - - if (permissionService.isSuperAdmin(server, email)) { - return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(permissionService.getAllPermissionIds(server))); - } - - Optional staffOpt = staffService.getStaffByEmail(server, email); - if (staffOpt.isEmpty()) { - return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of())); - } - - String roleId = RoleAuthorization.effectiveRoleId(server, staffOpt.get()); - Optional roleOpt = permissionService.getRoleById(server, roleId); - - return roleOpt.map(staffRole -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(staffRole.getPermissions()))) - .orElseGet(() -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of()))); - } -} +package gg.modl.backend.auth.controller; + +import gg.modl.backend.auth.AuthConfiguration; +import gg.modl.backend.auth.AuthResponseMessage; +import gg.modl.backend.auth.AuthService; +import gg.modl.backend.auth.EmailChangeService; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.proto.modl.v1.PanelAuthResponse; +import gg.modl.proto.modl.v1.PanelPermissionsResponse; +import gg.modl.proto.modl.v1.PanelProfileResponse; +import gg.modl.proto.modl.v1.PanelSendEmailCodeRequest; +import gg.modl.proto.modl.v1.PanelSessionsResponse; +import gg.modl.proto.modl.v1.PanelUpdateEmailRequest; +import gg.modl.proto.modl.v1.PanelUpdateEmailWithCodeRequest; +import gg.modl.proto.modl.v1.PanelUpdateProfileRequest; +import gg.modl.proto.modl.v1.PanelVerifyEmailCodeRequest; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_AUTH) +@RequiredArgsConstructor +@Slf4j +public class PanelAuthController { + private final AuthService authService; + private final SessionService sessionService; + private final AuthConfiguration authConfiguration; + private final StaffService staffService; + private final PermissionService permissionService; + private final CookieUtil cookieUtil; + private final EmailChangeService emailChangeService; + + @PostMapping("/send-email-code") + public ResponseEntity sendEmailCode( + HttpServletRequest request, + @RequestBody PanelSendEmailCodeRequest requestData) throws Exception { + + Server server = RequestUtil.getRequestServer(request); + + // Always return generic success to prevent email enumeration + if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); + } + + authService.sendUserLoginCode(server, requestData.getEmail()); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); + } + + @PostMapping("/verify-email-code") + public ResponseEntity verifyEmailCode( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody PanelVerifyEmailCodeRequest requestData) { + + Server server = RequestUtil.getRequestServer(request); + + // Return same error as invalid code to prevent email enumeration + if (!permissionService.isAuthorizedEmail(server, requestData.getEmail())) { + return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); + } + + boolean valid = authService.verifyCode(server, requestData.getEmail(), requestData.getCode()); + + if (!valid) { + return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); + } + + String clientIp = RequestUtil.getClientIp(request); + String userAgent = request.getHeader("User-Agent"); + AuthSessionData session = sessionService.createSession(server, requestData.getEmail(), clientIp, userAgent); + + response.addCookie(cookieUtil.createSessionCookie(session.getId())); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGIN_SUCCESS)); + } + + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { + Server server = RequestUtil.getRequestServer(request); + Set sessionIds = extractSessionIds(request); + + for (String sessionId : sessionIds) { + sessionService.invalidateSession(server, sessionId); + } + + expireSessionCookies(response); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); + } + + private Set extractSessionIds(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return Set.of(); + } + + return Arrays.stream(cookies) + .filter(cookie -> authConfiguration.getSessionCookieName().equals(cookie.getName())) + .map(Cookie::getValue) + .filter(value -> value != null && !value.isBlank()) + .collect(Collectors.toCollection(LinkedHashSet::new)); + } + + @PatchMapping("/profile") + public ResponseEntity updateProfile( + HttpServletRequest request, + @RequestBody PanelUpdateProfileRequest requestData) { + + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); + + String username = requestData.hasUsername() ? requestData.getUsername() : null; + String language = requestData.hasLanguage() ? requestData.getLanguage() : null; + String dateFormat = requestData.hasDateFormat() ? requestData.getDateFormat() : null; + + Optional result = staffService.updateOrCreateProfileUsername(server, email, username, isSuperAdmin, language, dateFormat); + if (result.isEmpty()) { + if (isSuperAdmin) { + String resolvedUsername = username != null ? username : "Admin"; + String resolvedLanguage = language != null ? language : "en"; + String resolvedDateFormat = dateFormat != null ? dateFormat : "MM/DD/YYYY"; + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + null, email, resolvedUsername, "Super Admin", resolvedUsername, resolvedLanguage, resolvedDateFormat)); + } + return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); + } + Staff staff = result.get(); + String role = isSuperAdmin ? "Super Admin" : permissionService.resolveRoleName(server, staff.getRoleId()); + String minecraftUsername = staff.getAssignedMinecraftUsername() != null + ? staff.getAssignedMinecraftUsername() + : staff.getUsername(); + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); + } + + @PostMapping("/email/send-code") + public ResponseEntity sendEmailChangeCode( + HttpServletRequest request, + @RequestBody PanelUpdateEmailRequest requestData) { + + String currentEmail = RequestUtil.getSessionEmail(request); + if (currentEmail == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + emailChangeService.sendChangeCode(server, currentEmail, requestData.getNewEmail()); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Verification code sent to new email.")); + } + + @PatchMapping("/email") + public ResponseEntity updateEmail( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody PanelUpdateEmailWithCodeRequest requestData) { + + String currentEmail = RequestUtil.getSessionEmail(request); + if (currentEmail == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + AuthSessionData newSession = emailChangeService.changeEmail( + server, currentEmail, requestData.getNewEmail(), requestData.getCode(), + RequestUtil.getClientIp(request), request.getHeader("User-Agent")); + response.addCookie(cookieUtil.createSessionCookie(newSession.getId())); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, "Email updated successfully.")); + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); + + Optional staffOpt = staffService.getStaffByEmail(server, email); + + if (staffOpt.isPresent()) { + Staff staff = staffOpt.get(); + String role = isSuperAdmin ? "Super Admin" : permissionService.resolveRoleName(server, staff.getRoleId()); + // Include Minecraft username if assigned, fall back to panel username + String minecraftUsername = staff.getAssignedMinecraftUsername() != null + ? staff.getAssignedMinecraftUsername() + : staff.getUsername(); + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); + } + + // Super Admin without a staff record - return default username + if (isSuperAdmin) { + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse(null, email, "Admin", "Super Admin", "Admin", "en", "MM/DD/YYYY")); + } + + return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); + } + + @GetMapping("/sessions") + public ResponseEntity getSessions(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + AuthSessionData currentSession = RequestUtil.getSession(request); + String currentSessionId = currentSession != null ? currentSession.getId() : null; + + List sessions = sessionService.findAllSessionsForEmail(server, email); + + return ResponseEntity.ok(PanelAuthProtoMapper.toSessionsResponse(sessions, currentSessionId)); + } + + @DeleteMapping("/sessions/{publicId}") + public ResponseEntity revokeSession( + HttpServletRequest request, + HttpServletResponse response, + @PathVariable String publicId) { + + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + Optional target = sessionService.findSessionByPublicId(server, email, publicId); + if (target.isEmpty()) { + return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Session not found")); + } + + String sessionId = target.get().getId(); + sessionService.invalidateSession(server, sessionId); + expireCookiesIfCurrentSession(request, response, sessionId); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); + } + + @DeleteMapping("/sessions") + public ResponseEntity revokeAllSessions(HttpServletRequest request, HttpServletResponse response) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toAuthResponse(false, "Not authenticated")); + } + + Server server = RequestUtil.getRequestServer(request); + sessionService.invalidateAllSessionsForEmail(server, email); + expireSessionCookies(response); + + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.LOGOUT_SUCCESS)); + } + + private void expireCookiesIfCurrentSession(HttpServletRequest request, HttpServletResponse response, String sessionId) { + AuthSessionData currentSession = RequestUtil.getSession(request); + if (currentSession != null && sessionId.equals(currentSession.getId())) { + expireSessionCookies(response); + } + } + + private void expireSessionCookies(HttpServletResponse response) { + for (Cookie cookie : cookieUtil.createExpiredSessionCookies()) { + response.addCookie(cookie); + } + } + + @GetMapping("/permissions") + public ResponseEntity getUserPermissions(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + return ResponseEntity.status(401).body(PanelAuthProtoMapper.toPermissionsResponse(List.of())); + } + + Server server = RequestUtil.getRequestServer(request); + + if (permissionService.isSuperAdmin(server, email)) { + return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(permissionService.getAllPermissionIds(server))); + } + + Optional staffOpt = staffService.getStaffByEmail(server, email); + if (staffOpt.isEmpty()) { + return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of())); + } + + String roleId = RoleAuthorization.effectiveRoleId(server, staffOpt.get()); + Optional roleOpt = permissionService.getRoleById(server, roleId); + + return roleOpt.map(staffRole -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(staffRole.getPermissions()))) + .orElseGet(() -> ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(List.of()))); + } +} diff --git a/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java b/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java index ce92a59..75fd9b2 100644 --- a/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java +++ b/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java @@ -1,186 +1,186 @@ -package gg.modl.backend.auth.controller; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.auth.WebAuthnService; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.proto.modl.v1.RenameWebAuthnCredentialRequest; -import gg.modl.proto.modl.v1.WebAuthnCredentialMutationResponse; -import gg.modl.proto.modl.v1.WebAuthnCredentialsResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import jakarta.validation.constraints.NotBlank; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_AUTH + "/webauthn") -@RequiredArgsConstructor -public class WebAuthnController { - private final WebAuthnService webAuthnService; - private final SessionService sessionService; - private final PermissionService permissionService; - private final ObjectMapper objectMapper; - private final CookieUtil cookieUtil; - - - @PostMapping("/register/options") - public ResponseEntity registerOptions(HttpServletRequest request) throws JsonProcessingException { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - WebAuthnService.StartRegistrationResult result = webAuthnService.startRegistration(server, email); - Object options = objectMapper.readValue(result.optionsJson(), Object.class); - return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); - } - - @PostMapping("/register/verify") - public ResponseEntity registerVerify( - HttpServletRequest request, - @RequestBody @Valid RegisterVerifyRequest body) throws Exception { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - webAuthnService.finishRegistration(server, email, body.challengeId(), body.response(), body.name()); - return ResponseEntity.ok(Map.of("success", true)); - } - - @GetMapping("/credentials") - public ResponseEntity listCredentials(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - List credentials = webAuthnService.listCredentials(server, email); - return ResponseEntity.ok(WebAuthnProtoMapper.toCredentialsResponse(credentials)); - } - - @PatchMapping("/credentials/{id}") - public ResponseEntity renameCredential( - HttpServletRequest request, - @PathVariable String id, - @RequestBody RenameWebAuthnCredentialRequest body) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - boolean updated = webAuthnService.renameCredential(server, email, id, body.getName()); - if (!updated) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); - } - - - @DeleteMapping("/credentials/{id}") - public ResponseEntity deleteCredential( - HttpServletRequest request, - @PathVariable String id) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } - - Server server = RequestUtil.getRequestServer(request); - boolean deleted = webAuthnService.deleteCredential(server, email, id); - if (!deleted) { - return ResponseEntity.notFound().build(); - } - return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); - } - - @PostMapping("/login/start") - public ResponseEntity loginStart(HttpServletRequest request) throws JsonProcessingException { - Server server = RequestUtil.getRequestServer(request); - WebAuthnService.StartAuthenticationResult result = webAuthnService.startDiscoverableAuthentication(server); - Object options = objectMapper.readValue(result.optionsJson(), Object.class); - return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); - } - - @PostMapping("/login/options") - public ResponseEntity loginOptions( - HttpServletRequest request, - @RequestBody @Valid LoginOptionsRequest body) throws JsonProcessingException { - Server server = RequestUtil.getRequestServer(request); - - // Prevent email enumeration: check if email is authorized first - if (!permissionService.isAuthorizedEmail(server, body.email())) { - return ResponseEntity.ok(Map.of("hasPasskeys", false)); - } - - boolean hasPasskeys = webAuthnService.checkHasPasskeys(server, body.email()); - if (!hasPasskeys) { - return ResponseEntity.ok(Map.of("hasPasskeys", false)); - } - - WebAuthnService.StartAuthenticationResult result = webAuthnService.startAuthentication(server, body.email()); - Object options = objectMapper.readValue(result.optionsJson(), Object.class); - return ResponseEntity.ok(Map.of( - "hasPasskeys", true, - "challengeId", result.challengeId(), - "options", options - )); - } - - - @PostMapping("/login/verify") - public ResponseEntity loginVerify( - HttpServletRequest request, - HttpServletResponse response, - @RequestBody @Valid LoginVerifyRequest body) throws Exception { - Server server = RequestUtil.getRequestServer(request); - - String email = webAuthnService.finishAuthentication(server, body.challengeId(), body.response(), - candidateEmail -> permissionService.isAuthorizedEmail(server, candidateEmail)); - - AuthSessionData session = sessionService.createSession(server, email, RequestUtil.getClientIp(request), request.getHeader("User-Agent")); - response.addCookie(cookieUtil.createSessionCookie(session.getId())); - - return ResponseEntity.ok(Map.of("success", true)); - } - - - public record RegisterVerifyRequest( - @NotBlank @Size(max = 256) String challengeId, - @NotBlank @Size(max = 10_000) String response, - @Size(max = 128) String name - ) {} - - public record LoginOptionsRequest(@Email @NotBlank @Size(max = RequestValidationLimits.EMAIL_MAX_LENGTH) String email) {} - - public record LoginVerifyRequest( - @NotBlank @Size(max = 256) String challengeId, - @NotBlank @Size(max = 10_000) String response - ) {} -} +package gg.modl.backend.auth.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.auth.WebAuthnService; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.proto.modl.v1.RenameWebAuthnCredentialRequest; +import gg.modl.proto.modl.v1.WebAuthnCredentialMutationResponse; +import gg.modl.proto.modl.v1.WebAuthnCredentialsResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_AUTH + "/webauthn") +@RequiredArgsConstructor +public class WebAuthnController { + private final WebAuthnService webAuthnService; + private final SessionService sessionService; + private final PermissionService permissionService; + private final ObjectMapper objectMapper; + private final CookieUtil cookieUtil; + + + @PostMapping("/register/options") + public ResponseEntity registerOptions(HttpServletRequest request) throws JsonProcessingException { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + + Server server = RequestUtil.getRequestServer(request); + WebAuthnService.StartRegistrationResult result = webAuthnService.startRegistration(server, email); + Object options = objectMapper.readValue(result.optionsJson(), Object.class); + return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); + } + + @PostMapping("/register/verify") + public ResponseEntity registerVerify( + HttpServletRequest request, + @RequestBody @Valid RegisterVerifyRequest body) throws Exception { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + + Server server = RequestUtil.getRequestServer(request); + webAuthnService.finishRegistration(server, email, body.challengeId(), body.response(), body.name()); + return ResponseEntity.ok(Map.of("success", true)); + } + + @GetMapping("/credentials") + public ResponseEntity listCredentials(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + + Server server = RequestUtil.getRequestServer(request); + List credentials = webAuthnService.listCredentials(server, email); + return ResponseEntity.ok(WebAuthnProtoMapper.toCredentialsResponse(credentials)); + } + + @PatchMapping("/credentials/{id}") + public ResponseEntity renameCredential( + HttpServletRequest request, + @PathVariable String id, + @RequestBody RenameWebAuthnCredentialRequest body) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + + Server server = RequestUtil.getRequestServer(request); + boolean updated = webAuthnService.renameCredential(server, email, id, body.getName()); + if (!updated) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); + } + + + @DeleteMapping("/credentials/{id}") + public ResponseEntity deleteCredential( + HttpServletRequest request, + @PathVariable String id) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + + Server server = RequestUtil.getRequestServer(request); + boolean deleted = webAuthnService.deleteCredential(server, email, id); + if (!deleted) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(WebAuthnProtoMapper.toMutationResponse(true)); + } + + @PostMapping("/login/start") + public ResponseEntity loginStart(HttpServletRequest request) throws JsonProcessingException { + Server server = RequestUtil.getRequestServer(request); + WebAuthnService.StartAuthenticationResult result = webAuthnService.startDiscoverableAuthentication(server); + Object options = objectMapper.readValue(result.optionsJson(), Object.class); + return ResponseEntity.ok(Map.of("challengeId", result.challengeId(), "options", options)); + } + + @PostMapping("/login/options") + public ResponseEntity loginOptions( + HttpServletRequest request, + @RequestBody @Valid LoginOptionsRequest body) throws JsonProcessingException { + Server server = RequestUtil.getRequestServer(request); + + // Prevent email enumeration: check if email is authorized first + if (!permissionService.isAuthorizedEmail(server, body.email())) { + return ResponseEntity.ok(Map.of("hasPasskeys", false)); + } + + boolean hasPasskeys = webAuthnService.checkHasPasskeys(server, body.email()); + if (!hasPasskeys) { + return ResponseEntity.ok(Map.of("hasPasskeys", false)); + } + + WebAuthnService.StartAuthenticationResult result = webAuthnService.startAuthentication(server, body.email()); + Object options = objectMapper.readValue(result.optionsJson(), Object.class); + return ResponseEntity.ok(Map.of( + "hasPasskeys", true, + "challengeId", result.challengeId(), + "options", options + )); + } + + + @PostMapping("/login/verify") + public ResponseEntity loginVerify( + HttpServletRequest request, + HttpServletResponse response, + @RequestBody @Valid LoginVerifyRequest body) throws Exception { + Server server = RequestUtil.getRequestServer(request); + + String email = webAuthnService.finishAuthentication(server, body.challengeId(), body.response(), + candidateEmail -> permissionService.isAuthorizedEmail(server, candidateEmail)); + + AuthSessionData session = sessionService.createSession(server, email, RequestUtil.getClientIp(request), request.getHeader("User-Agent")); + response.addCookie(cookieUtil.createSessionCookie(session.getId())); + + return ResponseEntity.ok(Map.of("success", true)); + } + + + public record RegisterVerifyRequest( + @NotBlank @Size(max = 256) String challengeId, + @NotBlank @Size(max = 10_000) String response, + @Size(max = 128) String name + ) {} + + public record LoginOptionsRequest(@Email @NotBlank @Size(max = RequestValidationLimits.EMAIL_MAX_LENGTH) String email) {} + + public record LoginVerifyRequest( + @NotBlank @Size(max = 256) String challengeId, + @NotBlank @Size(max = 10_000) String response + ) {} +} diff --git a/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java b/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java index 112720a..62c79ed 100644 --- a/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java +++ b/src/main/java/gg/modl/backend/auth/data/WebAuthnChallenge.java @@ -1,30 +1,30 @@ -package gg.modl.backend.auth.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.WEBAUTHN_CHALLENGES) -@GenerateMongoFields -@Data -@NoArgsConstructor -@AllArgsConstructor -public class WebAuthnChallenge { - @Id - private String id; - - @Field("challengeJson") - private String challengeJson; - - @Field("email") - private String email; - - @Field("expiresAt") - private Date expiresAt; -} +package gg.modl.backend.auth.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.WEBAUTHN_CHALLENGES) +@GenerateMongoFields +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WebAuthnChallenge { + @Id + private String id; + + @Field("challengeJson") + private String challengeJson; + + @Field("email") + private String email; + + @Field("expiresAt") + private Date expiresAt; +} diff --git a/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java b/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java index 05b0639..1497465 100644 --- a/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java +++ b/src/main/java/gg/modl/backend/auth/data/WebAuthnCredential.java @@ -1,45 +1,45 @@ -package gg.modl.backend.auth.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.WEBAUTHN_CREDENTIALS) -@GenerateMongoFields -@Data -@NoArgsConstructor -@AllArgsConstructor -public class WebAuthnCredential { - @Id - private String id; - - @Field("email") - private String email; - - @Field("credentialId") - private String credentialId; - - @Field("publicKeyCose") - private byte[] publicKeyCose; - - @Field("signatureCount") - private long signatureCount; - - @Field("userHandle") - private String userHandle; - - @Field("name") - private String name; - - @Field("createdAt") - private Date createdAt; - - @Field("lastUsedAt") - private Date lastUsedAt; -} +package gg.modl.backend.auth.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.WEBAUTHN_CREDENTIALS) +@GenerateMongoFields +@Data +@NoArgsConstructor +@AllArgsConstructor +public class WebAuthnCredential { + @Id + private String id; + + @Field("email") + private String email; + + @Field("credentialId") + private String credentialId; + + @Field("publicKeyCose") + private byte[] publicKeyCose; + + @Field("signatureCount") + private long signatureCount; + + @Field("userHandle") + private String userHandle; + + @Field("name") + private String name; + + @Field("createdAt") + private Date createdAt; + + @Field("lastUsedAt") + private Date lastUsedAt; +} diff --git a/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java b/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java index 8eaaeef..839ea31 100644 --- a/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java +++ b/src/main/java/gg/modl/backend/auth/session/AuthSessionData.java @@ -1,37 +1,37 @@ -package gg.modl.backend.auth.session; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; - -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.SESSIONS) -@GenerateMongoFields -@Data -@NoArgsConstructor -@AllArgsConstructor -public class AuthSessionData { - @Id - private String id; - - @Field("email") - private String email; - - @Field("createdAt") - private Date createdAt; - - @Field("expiresAt") - private Date expiresAt; - - @Field("ipAddress") - private String ipAddress; - - @Field("userAgent") - private String userAgent; -} +package gg.modl.backend.auth.session; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.SESSIONS) +@GenerateMongoFields +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AuthSessionData { + @Id + private String id; + + @Field("email") + private String email; + + @Field("createdAt") + private Date createdAt; + + @Field("expiresAt") + private Date expiresAt; + + @Field("ipAddress") + private String ipAddress; + + @Field("userAgent") + private String userAgent; +} diff --git a/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java b/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java index 8528e15..83818cb 100644 --- a/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java +++ b/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java @@ -1,46 +1,46 @@ -package gg.modl.backend.billing.config; - -import com.stripe.Stripe; -import com.stripe.StripeClient; -import jakarta.annotation.PostConstruct; -import lombok.Getter; -import lombok.Setter; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Configuration -@ConfigurationProperties(prefix = "modl.stripe") -@Validated -@Getter -@Setter -@Slf4j -public class StripeConfiguration { - private String secretKey = ""; - private String webhookSecret = ""; - private String priceId = ""; - - @PostConstruct - public void init() { - if (secretKey != null && !secretKey.isBlank()) { - Stripe.apiKey = secretKey; - log.info("Stripe API initialized"); - } else { - log.warn("STRIPE_SECRET_KEY not found. Billing features will be disabled."); - } - } - - @Bean - public StripeClient stripeClient() { - if (secretKey == null || secretKey.isBlank()) { - return null; - } - return new StripeClient(secretKey); - } - - public boolean isConfigured() { - return secretKey != null && !secretKey.isBlank(); - } -} +package gg.modl.backend.billing.config; + +import com.stripe.Stripe; +import com.stripe.StripeClient; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Configuration +@ConfigurationProperties(prefix = "modl.stripe") +@Validated +@Getter +@Setter +@Slf4j +public class StripeConfiguration { + private String secretKey = ""; + private String webhookSecret = ""; + private String priceId = ""; + + @PostConstruct + public void init() { + if (secretKey != null && !secretKey.isBlank()) { + Stripe.apiKey = secretKey; + log.info("Stripe API initialized"); + } else { + log.warn("STRIPE_SECRET_KEY not found. Billing features will be disabled."); + } + } + + @Bean + public StripeClient stripeClient() { + if (secretKey == null || secretKey.isBlank()) { + return null; + } + return new StripeClient(secretKey); + } + + public boolean isConfigured() { + return secretKey != null && !secretKey.isBlank(); + } +} diff --git a/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java b/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java index ef2b356..ab4d9dd 100644 --- a/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java +++ b/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java @@ -1,127 +1,127 @@ -package gg.modl.backend.billing.controller; - -import gg.modl.backend.billing.service.BillingService; -import gg.modl.backend.billing.service.UsageTrackingService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.BillingStatusResponse; -import gg.modl.proto.modl.v1.CancelResponse; -import gg.modl.proto.modl.v1.CheckoutSessionResponse; -import gg.modl.proto.modl.v1.PortalSessionResponse; -import gg.modl.proto.modl.v1.ResubscribeResponse; -import gg.modl.proto.modl.v1.UpdateOverageLimitsRequest; -import gg.modl.proto.modl.v1.UpdateOverageLimitsResponse; -import gg.modl.proto.modl.v1.UpdateStorageLimitRequest; -import gg.modl.proto.modl.v1.UpdateStorageLimitResponse; -import gg.modl.proto.modl.v1.UsageBillingSettingsRequest; -import gg.modl.proto.modl.v1.UsageBillingSettingsResponse; -import gg.modl.proto.modl.v1.UsageResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_BILLING) -@RequiredArgsConstructor -public class PanelBillingController { - private final BillingService billingService; - private final UsageTrackingService usageTrackingService; - - @PostMapping("/checkout-session") - public ResponseEntity createCheckoutSession(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toCheckoutSessionResponse(billingService.createCheckoutSession(server))); - } - - @PostMapping("/portal-session") - public ResponseEntity createPortalSession(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toPortalSessionResponse(billingService.createPortalSession(server))); - } - - @PostMapping("/cancel") - public ResponseEntity cancelSubscription(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toCancelResponse(billingService.cancelSubscription(server))); - } - - @PostMapping("/resubscribe") - public ResponseEntity resubscribe(HttpServletRequest request) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toResubscribeResponse(billingService.resubscribe(server))); - } - - @GetMapping("/status") - public ResponseEntity getBillingStatus(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - billingService.reconcileBillingStatus(server); - return ResponseEntity.ok(PanelBillingProtoMapper.toBillingStatusResponse(billingService.getBillingStatus(server))); - } - - @GetMapping("/usage") - public ResponseEntity getUsage(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelBillingProtoMapper.toUsageResponse(usageTrackingService.getUsage(server))); - } - - @PostMapping("/usage-settings") - public ResponseEntity updateUsageBillingSettings( - @RequestBody UsageBillingSettingsRequest settingsRequest, - HttpServletRequest request - ) { - billingService.requireStripeConfigured(); - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - return ResponseEntity.ok(PanelBillingProtoMapper.toUsageBillingSettingsResponse( - usageTrackingService.updateUsageBillingSettings(server, settingsRequest.getEnabled()))); - } - - @PostMapping("/storage-limit") - public ResponseEntity updateStorageLimit( - @RequestBody UpdateStorageLimitRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - long maxStorageLimitBytes = body.getMaxStorageLimitBytes(); - usageTrackingService.updateStorageLimit(server, maxStorageLimitBytes); - - return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateStorageLimitResponse(maxStorageLimitBytes)); - } - - @PostMapping("/overage-limits") - public ResponseEntity updateOverageLimits( - @RequestBody UpdateOverageLimitsRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); - - int maxStorageOverageGB = body.hasMaxStorageOverageGbValue() ? body.getMaxStorageOverageGbValue() : body.getMaxStorageOverageGb(); - int maxAiOverageRequests = body.hasMaxAiOverageRequestsValue() ? body.getMaxAiOverageRequestsValue() : body.getMaxAiOverageRequests(); - - long maxStorageLimitBytes = usageTrackingService.updateOverageLimits(server, maxStorageOverageGB, maxAiOverageRequests); - - return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateOverageLimitsResponse(maxStorageLimitBytes, maxAiOverageRequests)); - } -} +package gg.modl.backend.billing.controller; + +import gg.modl.backend.billing.service.BillingService; +import gg.modl.backend.billing.service.UsageTrackingService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.BillingStatusResponse; +import gg.modl.proto.modl.v1.CancelResponse; +import gg.modl.proto.modl.v1.CheckoutSessionResponse; +import gg.modl.proto.modl.v1.PortalSessionResponse; +import gg.modl.proto.modl.v1.ResubscribeResponse; +import gg.modl.proto.modl.v1.UpdateOverageLimitsRequest; +import gg.modl.proto.modl.v1.UpdateOverageLimitsResponse; +import gg.modl.proto.modl.v1.UpdateStorageLimitRequest; +import gg.modl.proto.modl.v1.UpdateStorageLimitResponse; +import gg.modl.proto.modl.v1.UsageBillingSettingsRequest; +import gg.modl.proto.modl.v1.UsageBillingSettingsResponse; +import gg.modl.proto.modl.v1.UsageResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_BILLING) +@RequiredArgsConstructor +public class PanelBillingController { + private final BillingService billingService; + private final UsageTrackingService usageTrackingService; + + @PostMapping("/checkout-session") + public ResponseEntity createCheckoutSession(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toCheckoutSessionResponse(billingService.createCheckoutSession(server))); + } + + @PostMapping("/portal-session") + public ResponseEntity createPortalSession(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toPortalSessionResponse(billingService.createPortalSession(server))); + } + + @PostMapping("/cancel") + public ResponseEntity cancelSubscription(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toCancelResponse(billingService.cancelSubscription(server))); + } + + @PostMapping("/resubscribe") + public ResponseEntity resubscribe(HttpServletRequest request) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toResubscribeResponse(billingService.resubscribe(server))); + } + + @GetMapping("/status") + public ResponseEntity getBillingStatus(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + billingService.reconcileBillingStatus(server); + return ResponseEntity.ok(PanelBillingProtoMapper.toBillingStatusResponse(billingService.getBillingStatus(server))); + } + + @GetMapping("/usage") + public ResponseEntity getUsage(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelBillingProtoMapper.toUsageResponse(usageTrackingService.getUsage(server))); + } + + @PostMapping("/usage-settings") + public ResponseEntity updateUsageBillingSettings( + @RequestBody UsageBillingSettingsRequest settingsRequest, + HttpServletRequest request + ) { + billingService.requireStripeConfigured(); + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + return ResponseEntity.ok(PanelBillingProtoMapper.toUsageBillingSettingsResponse( + usageTrackingService.updateUsageBillingSettings(server, settingsRequest.getEnabled()))); + } + + @PostMapping("/storage-limit") + public ResponseEntity updateStorageLimit( + @RequestBody UpdateStorageLimitRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + long maxStorageLimitBytes = body.getMaxStorageLimitBytes(); + usageTrackingService.updateStorageLimit(server, maxStorageLimitBytes); + + return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateStorageLimitResponse(maxStorageLimitBytes)); + } + + @PostMapping("/overage-limits") + public ResponseEntity updateOverageLimits( + @RequestBody UpdateOverageLimitsRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); + + int maxStorageOverageGB = body.hasMaxStorageOverageGbValue() ? body.getMaxStorageOverageGbValue() : body.getMaxStorageOverageGb(); + int maxAiOverageRequests = body.hasMaxAiOverageRequestsValue() ? body.getMaxAiOverageRequestsValue() : body.getMaxAiOverageRequests(); + + long maxStorageLimitBytes = usageTrackingService.updateOverageLimits(server, maxStorageOverageGB, maxAiOverageRequests); + + return ResponseEntity.ok(PanelBillingProtoMapper.toUpdateOverageLimitsResponse(maxStorageLimitBytes, maxAiOverageRequests)); + } +} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java index 4eadb9d..c16abf9 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/CancelResponse.java @@ -1,9 +1,9 @@ -package gg.modl.backend.billing.dto.response; - -import java.util.Date; - -public record CancelResponse( - boolean success, - String message, - Date cancelsAt -) {} +package gg.modl.backend.billing.dto.response; + +import java.util.Date; + +public record CancelResponse( + boolean success, + String message, + Date cancelsAt +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java index 31f4f58..3ac5abe 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/CheckoutSessionResponse.java @@ -1,6 +1,6 @@ -package gg.modl.backend.billing.dto.response; - -public record CheckoutSessionResponse( - String sessionId, - String url -) {} +package gg.modl.backend.billing.dto.response; + +public record CheckoutSessionResponse( + String sessionId, + String url +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java index 5f0802c..a742891 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/PortalSessionResponse.java @@ -1,5 +1,5 @@ -package gg.modl.backend.billing.dto.response; - -public record PortalSessionResponse( - String url -) {} +package gg.modl.backend.billing.dto.response; + +public record PortalSessionResponse( + String url +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java index 35c406d..69b868c 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/ResubscribeResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.billing.dto.response; - -import java.util.Date; - -public record ResubscribeResponse( - boolean success, - String message, - SubscriptionInfo subscription -) { - public record SubscriptionInfo( - String id, - String status, - Date currentPeriodEnd - ) {} -} +package gg.modl.backend.billing.dto.response; + +import java.util.Date; + +public record ResubscribeResponse( + boolean success, + String message, + SubscriptionInfo subscription +) { + public record SubscriptionInfo( + String id, + String status, + Date currentPeriodEnd + ) {} +} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java index 7b67415..5537563 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/UsageBillingSettingsResponse.java @@ -1,7 +1,7 @@ -package gg.modl.backend.billing.dto.response; - -public record UsageBillingSettingsResponse( - boolean success, - String message, - boolean usageBillingEnabled -) {} +package gg.modl.backend.billing.dto.response; + +public record UsageBillingSettingsResponse( + boolean success, + String message, + boolean usageBillingEnabled +) {} diff --git a/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java b/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java index 8a7d88e..4300be9 100644 --- a/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java +++ b/src/main/java/gg/modl/backend/billing/dto/response/UsageResponse.java @@ -1,24 +1,24 @@ -package gg.modl.backend.billing.dto.response; - -import java.util.Date; - -public record UsageResponse( - Period period, - UsageMetric ai, - double totalOverageCost, - boolean usageBillingEnabled -) { - public record Period( - Date start, - Date end - ) {} - - public record UsageMetric( - double used, - double limit, - double overage, - double overageRate, - double overageCost, - double percentage - ) {} -} +package gg.modl.backend.billing.dto.response; + +import java.util.Date; + +public record UsageResponse( + Period period, + UsageMetric ai, + double totalOverageCost, + boolean usageBillingEnabled +) { + public record Period( + Date start, + Date end + ) {} + + public record UsageMetric( + double used, + double limit, + double overage, + double overageRate, + double overageCost, + double percentage + ) {} +} diff --git a/src/main/java/gg/modl/backend/billing/service/BillingService.java b/src/main/java/gg/modl/backend/billing/service/BillingService.java index c64c077..c214e77 100644 --- a/src/main/java/gg/modl/backend/billing/service/BillingService.java +++ b/src/main/java/gg/modl/backend/billing/service/BillingService.java @@ -1,238 +1,238 @@ -package gg.modl.backend.billing.service; - -import com.stripe.exception.StripeException; -import com.stripe.model.Subscription; -import com.stripe.model.checkout.Session; -import gg.modl.backend.infrastructure.exception.ConflictException; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.billing.dto.response.BillingStatusResponse; -import gg.modl.backend.billing.dto.response.CancelResponse; -import gg.modl.backend.billing.dto.response.CheckoutSessionResponse; -import gg.modl.backend.billing.dto.response.PortalSessionResponse; -import gg.modl.backend.billing.dto.response.ResubscribeResponse; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class BillingService { - private final StripeService stripeService; - private final ServerMutationHelper serverMutationHelper; - private final PermissionService permissionService; - - public void requireStripeConfigured() { - if (!stripeService.isConfigured()) { - throw new ExternalServiceException("Billing service unavailable. Stripe not configured."); - } - } - - public void requireSuperAdmin(Server server, String email) { - if (email == null || !permissionService.isSuperAdmin(server, email)) { - throw new ForbiddenException("Only the super admin can manage billing"); - } - } - - public void syncCustomerEmail(Server server, String newEmail) { - String customerId = server.getStripeCustomerId(); - if (!stripeService.isConfigured() || customerId == null || customerId.isBlank()) { - return; - } - - try { - stripeService.updateCustomerEmail(customerId, newEmail); - } catch (StripeException e) { - log.warn("Failed to sync Stripe customer email for server {}; billing email may be stale", server.getId(), e); - } - } - - public CheckoutSessionResponse createCheckoutSession(Server server) { - try { - String customerId = server.getStripeCustomerId(); - - if (customerId == null || customerId.isBlank()) { - customerId = stripeService.createCustomer(server); - String createdCustomerId = customerId; - serverMutationHelper.mutate(server, current -> current.setStripeCustomerId(createdCustomerId)); - } - - Session session = stripeService.createCheckoutSession(customerId, server.getCustomDomain()); - return new CheckoutSessionResponse(session.getId(), session.getUrl()); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to create checkout session", e); - } - } - - public PortalSessionResponse createPortalSession(Server server) { - if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { - throw new ResourceNotFoundException("Customer ID not found for server"); - } - - try { - com.stripe.model.billingportal.Session session = stripeService.createPortalSession(server.getStripeCustomerId(), server.getCustomDomain()); - return new PortalSessionResponse(session.getUrl()); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to create portal session", e); - } - } - - public CancelResponse cancelSubscription(Server server) { - if (server.getStripeSubscriptionId() == null || server.getStripeSubscriptionId().isBlank()) { - throw new ResourceNotFoundException("No active subscription found to cancel"); - } - - try { - Subscription canceledSubscription = stripeService.cancelSubscription(server.getStripeSubscriptionId()); - - Date periodEndDate = server.getCurrentPeriodEnd(); - if (periodEndDate == null) { - periodEndDate = stripeService.extractPeriodEnd(canceledSubscription); - } - - Date finalPeriodEndDate = periodEndDate; - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.CANCELED); - if (finalPeriodEndDate != null) { - current.setCurrentPeriodEnd(finalPeriodEndDate); - } - }); - - return new CancelResponse( - true, - "Subscription cancelled successfully. Access will continue until the end of your current billing period.", - periodEndDate - ); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to cancel subscription", e); - } - } - - public BillingStatusResponse getBillingStatus(Server server) { - SubscriptionStatus currentStatus = server.getSubscriptionStatus(); - return new BillingStatusResponse( - server.getPlan() != null ? server.getPlan().name() : null, - currentStatus != null ? currentStatus.name() : null, - server.getCurrentPeriodStart(), - server.getCurrentPeriodEnd(), - server.getCustomDomainGrandfathered(), - server.getMaxStorageLimitBytes(), - server.getMaxAiOverageRequests() - ); - } - - public void reconcileBillingStatus(Server server) { - SubscriptionStatus currentStatus = server.getSubscriptionStatus(); - boolean reconcilable = server.getStripeSubscriptionId() != null - && (currentStatus == null || currentStatus == SubscriptionStatus.ACTIVE || currentStatus == SubscriptionStatus.CANCELED) - && stripeService.isConfigured(); - if (!reconcilable) { - return; - } - - try { - Subscription subscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); - SubscriptionStatus effectiveStatus = SubscriptionStatus.fromStripeOrInactive(stripeService.getEffectiveStatus(subscription)); - Date periodStartDate = stripeService.extractPeriodStart(subscription); - Date periodEndDate = stripeService.extractPeriodEnd(subscription); - - boolean needsUpdate = effectiveStatus != currentStatus - || periodDrifted(server.getCurrentPeriodStart(), periodStartDate) - || periodDrifted(server.getCurrentPeriodEnd(), periodEndDate); - - if (needsUpdate) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(effectiveStatus); - if (periodStartDate != null) { - current.setCurrentPeriodStart(periodStartDate); - } - if (periodEndDate != null) { - current.setCurrentPeriodEnd(periodEndDate); - } - }); - } - } catch (StripeException exception) { - log.error("Error reconciling subscription from Stripe for server {}", server.getId(), exception); - } - } - - private boolean periodDrifted(Date persisted, Date fresh) { - return fresh != null && (persisted == null || Math.abs(persisted.getTime() - fresh.getTime()) > 1000); - } - - public ResubscribeResponse resubscribe(Server server) { - if (server.getSubscriptionStatus() != SubscriptionStatus.CANCELED) { - throw new ConflictException("No cancelled subscription found to reactivate."); - } - - try { - Subscription subscriptionResult; - - if (server.getStripeSubscriptionId() != null) { - try { - Subscription existingSubscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); - - if ("active".equals(existingSubscription.getStatus()) && Boolean.TRUE.equals(existingSubscription.getCancelAtPeriodEnd())) { - subscriptionResult = stripeService.reactivateSubscription(server.getStripeSubscriptionId()); - } else if ("canceled".equals(existingSubscription.getStatus())) { - subscriptionResult = createNewSubscription(server); - } else { - throw new ConflictException("Subscription is not in a cancelled state that can be reactivated."); - } - } catch (StripeException exception) { - if ("resource_missing".equals(exception.getCode())) { - subscriptionResult = createNewSubscription(server); - } else { - throw exception; - } - } - } else { - subscriptionResult = createNewSubscription(server); - } - - Date periodStartDate = stripeService.extractPeriodStart(subscriptionResult); - Date periodEndDate = stripeService.extractPeriodEnd(subscriptionResult); - String subscriptionId = subscriptionResult.getId(); - SubscriptionStatus subscriptionStatus = SubscriptionStatus.fromStripeOrInactive(subscriptionResult.getStatus()); - - serverMutationHelper.mutate(server, current -> { - current.setStripeSubscriptionId(subscriptionId); - current.setSubscriptionStatus(subscriptionStatus); - current.setPlan(ServerPlan.PREMIUM); - if (periodStartDate != null) { - current.setCurrentPeriodStart(periodStartDate); - } - if (periodEndDate != null) { - current.setCurrentPeriodEnd(periodEndDate); - } - }); - - return new ResubscribeResponse( - true, - "Subscription reactivated successfully! Your premium features are now active.", - new ResubscribeResponse.SubscriptionInfo( - subscriptionResult.getId(), - subscriptionResult.getStatus(), - periodEndDate - ) - ); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to resubscribe", e); - } - } - - private Subscription createNewSubscription(Server server) throws StripeException { - if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { - throw new ResourceNotFoundException("No Stripe customer ID found. Cannot create subscription."); - } - return stripeService.createSubscription(server.getStripeCustomerId()); - } -} +package gg.modl.backend.billing.service; + +import com.stripe.exception.StripeException; +import com.stripe.model.Subscription; +import com.stripe.model.checkout.Session; +import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.billing.dto.response.BillingStatusResponse; +import gg.modl.backend.billing.dto.response.CancelResponse; +import gg.modl.backend.billing.dto.response.CheckoutSessionResponse; +import gg.modl.backend.billing.dto.response.PortalSessionResponse; +import gg.modl.backend.billing.dto.response.ResubscribeResponse; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BillingService { + private final StripeService stripeService; + private final ServerMutationHelper serverMutationHelper; + private final PermissionService permissionService; + + public void requireStripeConfigured() { + if (!stripeService.isConfigured()) { + throw new ExternalServiceException("Billing service unavailable. Stripe not configured."); + } + } + + public void requireSuperAdmin(Server server, String email) { + if (email == null || !permissionService.isSuperAdmin(server, email)) { + throw new ForbiddenException("Only the super admin can manage billing"); + } + } + + public void syncCustomerEmail(Server server, String newEmail) { + String customerId = server.getStripeCustomerId(); + if (!stripeService.isConfigured() || customerId == null || customerId.isBlank()) { + return; + } + + try { + stripeService.updateCustomerEmail(customerId, newEmail); + } catch (StripeException e) { + log.warn("Failed to sync Stripe customer email for server {}; billing email may be stale", server.getId(), e); + } + } + + public CheckoutSessionResponse createCheckoutSession(Server server) { + try { + String customerId = server.getStripeCustomerId(); + + if (customerId == null || customerId.isBlank()) { + customerId = stripeService.createCustomer(server); + String createdCustomerId = customerId; + serverMutationHelper.mutate(server, current -> current.setStripeCustomerId(createdCustomerId)); + } + + Session session = stripeService.createCheckoutSession(customerId, server.getCustomDomain()); + return new CheckoutSessionResponse(session.getId(), session.getUrl()); + } catch (StripeException e) { + throw new ExternalServiceException("Failed to create checkout session", e); + } + } + + public PortalSessionResponse createPortalSession(Server server) { + if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { + throw new ResourceNotFoundException("Customer ID not found for server"); + } + + try { + com.stripe.model.billingportal.Session session = stripeService.createPortalSession(server.getStripeCustomerId(), server.getCustomDomain()); + return new PortalSessionResponse(session.getUrl()); + } catch (StripeException e) { + throw new ExternalServiceException("Failed to create portal session", e); + } + } + + public CancelResponse cancelSubscription(Server server) { + if (server.getStripeSubscriptionId() == null || server.getStripeSubscriptionId().isBlank()) { + throw new ResourceNotFoundException("No active subscription found to cancel"); + } + + try { + Subscription canceledSubscription = stripeService.cancelSubscription(server.getStripeSubscriptionId()); + + Date periodEndDate = server.getCurrentPeriodEnd(); + if (periodEndDate == null) { + periodEndDate = stripeService.extractPeriodEnd(canceledSubscription); + } + + Date finalPeriodEndDate = periodEndDate; + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.CANCELED); + if (finalPeriodEndDate != null) { + current.setCurrentPeriodEnd(finalPeriodEndDate); + } + }); + + return new CancelResponse( + true, + "Subscription cancelled successfully. Access will continue until the end of your current billing period.", + periodEndDate + ); + } catch (StripeException e) { + throw new ExternalServiceException("Failed to cancel subscription", e); + } + } + + public BillingStatusResponse getBillingStatus(Server server) { + SubscriptionStatus currentStatus = server.getSubscriptionStatus(); + return new BillingStatusResponse( + server.getPlan() != null ? server.getPlan().name() : null, + currentStatus != null ? currentStatus.name() : null, + server.getCurrentPeriodStart(), + server.getCurrentPeriodEnd(), + server.getCustomDomainGrandfathered(), + server.getMaxStorageLimitBytes(), + server.getMaxAiOverageRequests() + ); + } + + public void reconcileBillingStatus(Server server) { + SubscriptionStatus currentStatus = server.getSubscriptionStatus(); + boolean reconcilable = server.getStripeSubscriptionId() != null + && (currentStatus == null || currentStatus == SubscriptionStatus.ACTIVE || currentStatus == SubscriptionStatus.CANCELED) + && stripeService.isConfigured(); + if (!reconcilable) { + return; + } + + try { + Subscription subscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); + SubscriptionStatus effectiveStatus = SubscriptionStatus.fromStripeOrInactive(stripeService.getEffectiveStatus(subscription)); + Date periodStartDate = stripeService.extractPeriodStart(subscription); + Date periodEndDate = stripeService.extractPeriodEnd(subscription); + + boolean needsUpdate = effectiveStatus != currentStatus + || periodDrifted(server.getCurrentPeriodStart(), periodStartDate) + || periodDrifted(server.getCurrentPeriodEnd(), periodEndDate); + + if (needsUpdate) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(effectiveStatus); + if (periodStartDate != null) { + current.setCurrentPeriodStart(periodStartDate); + } + if (periodEndDate != null) { + current.setCurrentPeriodEnd(periodEndDate); + } + }); + } + } catch (StripeException exception) { + log.error("Error reconciling subscription from Stripe for server {}", server.getId(), exception); + } + } + + private boolean periodDrifted(Date persisted, Date fresh) { + return fresh != null && (persisted == null || Math.abs(persisted.getTime() - fresh.getTime()) > 1000); + } + + public ResubscribeResponse resubscribe(Server server) { + if (server.getSubscriptionStatus() != SubscriptionStatus.CANCELED) { + throw new ConflictException("No cancelled subscription found to reactivate."); + } + + try { + Subscription subscriptionResult; + + if (server.getStripeSubscriptionId() != null) { + try { + Subscription existingSubscription = stripeService.retrieveSubscription(server.getStripeSubscriptionId()); + + if ("active".equals(existingSubscription.getStatus()) && Boolean.TRUE.equals(existingSubscription.getCancelAtPeriodEnd())) { + subscriptionResult = stripeService.reactivateSubscription(server.getStripeSubscriptionId()); + } else if ("canceled".equals(existingSubscription.getStatus())) { + subscriptionResult = createNewSubscription(server); + } else { + throw new ConflictException("Subscription is not in a cancelled state that can be reactivated."); + } + } catch (StripeException exception) { + if ("resource_missing".equals(exception.getCode())) { + subscriptionResult = createNewSubscription(server); + } else { + throw exception; + } + } + } else { + subscriptionResult = createNewSubscription(server); + } + + Date periodStartDate = stripeService.extractPeriodStart(subscriptionResult); + Date periodEndDate = stripeService.extractPeriodEnd(subscriptionResult); + String subscriptionId = subscriptionResult.getId(); + SubscriptionStatus subscriptionStatus = SubscriptionStatus.fromStripeOrInactive(subscriptionResult.getStatus()); + + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(subscriptionId); + current.setSubscriptionStatus(subscriptionStatus); + current.setPlan(ServerPlan.PREMIUM); + if (periodStartDate != null) { + current.setCurrentPeriodStart(periodStartDate); + } + if (periodEndDate != null) { + current.setCurrentPeriodEnd(periodEndDate); + } + }); + + return new ResubscribeResponse( + true, + "Subscription reactivated successfully! Your premium features are now active.", + new ResubscribeResponse.SubscriptionInfo( + subscriptionResult.getId(), + subscriptionResult.getStatus(), + periodEndDate + ) + ); + } catch (StripeException e) { + throw new ExternalServiceException("Failed to resubscribe", e); + } + } + + private Subscription createNewSubscription(Server server) throws StripeException { + if (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank()) { + throw new ResourceNotFoundException("No Stripe customer ID found. Cannot create subscription."); + } + return stripeService.createSubscription(server.getStripeCustomerId()); + } +} diff --git a/src/main/java/gg/modl/backend/billing/service/StripeService.java b/src/main/java/gg/modl/backend/billing/service/StripeService.java index 2bf87c4..dd73016 100644 --- a/src/main/java/gg/modl/backend/billing/service/StripeService.java +++ b/src/main/java/gg/modl/backend/billing/service/StripeService.java @@ -1,146 +1,146 @@ -package gg.modl.backend.billing.service; - -import com.stripe.exception.StripeException; -import com.stripe.model.Customer; -import com.stripe.model.Subscription; -import com.stripe.model.SubscriptionItem; -import com.stripe.model.billingportal.Session; -import com.stripe.param.CustomerCreateParams; -import com.stripe.param.CustomerUpdateParams; -import com.stripe.param.SubscriptionCreateParams; -import com.stripe.param.SubscriptionUpdateParams; -import com.stripe.param.billingportal.SessionCreateParams; -import com.stripe.param.checkout.SessionCreateParams.ConsentCollection; -import gg.modl.backend.billing.config.StripeConfiguration; -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class StripeService { - private final StripeConfiguration config; - private final ModlProperties modlProperties; - - public boolean isConfigured() { - return config.isConfigured(); - } - - public String createCustomer(Server server) throws StripeException { - CustomerCreateParams params = CustomerCreateParams.builder() - .setEmail(server.getAdminEmail()) - .setName(server.getServerName()) - .putMetadata("serverName", server.getCustomDomain()) - .build(); - - Customer customer = Customer.create(params); - return customer.getId(); - } - - public void updateCustomerEmail(String customerId, String newEmail) throws StripeException { - Customer customer = Customer.retrieve(customerId); - customer.update(CustomerUpdateParams.builder().setEmail(newEmail).build()); - } - - public com.stripe.model.checkout.Session createCheckoutSession(String customerId, String subdomain) throws StripeException { - String successUrl = String.format("https://%s.%s/panel/settings?session_id={CHECKOUT_SESSION_ID}", subdomain, modlProperties.getDomain()); - String cancelUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); - - com.stripe.param.checkout.SessionCreateParams params = com.stripe.param.checkout.SessionCreateParams.builder() - .setMode(com.stripe.param.checkout.SessionCreateParams.Mode.SUBSCRIPTION) - .setAllowPromotionCodes(true) - .setConsentCollection( - ConsentCollection.builder() - .setTermsOfService(ConsentCollection.TermsOfService.REQUIRED) - .build() - ) - .addLineItem( - com.stripe.param.checkout.SessionCreateParams.LineItem.builder() - .setPrice(config.getPriceId()) - .setQuantity(1L) - .build() - ) - .setCustomer(customerId) - .setSuccessUrl(successUrl) - .setCancelUrl(cancelUrl) - .build(); - - return com.stripe.model.checkout.Session.create(params); - } - - public Session createPortalSession(String customerId, String subdomain) throws StripeException { - String returnUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); - - SessionCreateParams params = SessionCreateParams.builder() - .setCustomer(customerId) - .setReturnUrl(returnUrl) - .build(); - - return Session.create(params); - } - - public Subscription cancelSubscription(String subscriptionId) throws StripeException { - Subscription subscription = Subscription.retrieve(subscriptionId); - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .setCancelAtPeriodEnd(true) - .build(); - return subscription.update(params); - } - - public Subscription retrieveSubscription(String subscriptionId) throws StripeException { - return Subscription.retrieve(subscriptionId); - } - - public Subscription reactivateSubscription(String subscriptionId) throws StripeException { - Subscription subscription = Subscription.retrieve(subscriptionId); - SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() - .setCancelAtPeriodEnd(false) - .build(); - return subscription.update(params); - } - - public Subscription createSubscription(String customerId) throws StripeException { - SubscriptionCreateParams params = SubscriptionCreateParams.builder() - .setCustomer(customerId) - .addItem( - SubscriptionCreateParams.Item.builder() - .setPrice(config.getPriceId()) - .build() - ) - .build(); - - return Subscription.create(params); - } - - public Date extractPeriodStart(Subscription subscription) { - if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { - SubscriptionItem item = subscription.getItems().getData().get(0); - if (item.getCurrentPeriodStart() != null) { - return new Date(item.getCurrentPeriodStart() * 1000); - } - } - return null; - } - - public Date extractPeriodEnd(Subscription subscription) { - if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { - SubscriptionItem item = subscription.getItems().getData().get(0); - if (item.getCurrentPeriodEnd() != null) { - return new Date(item.getCurrentPeriodEnd() * 1000); - } - } - return null; - } - - public String getEffectiveStatus(Subscription subscription) { - String status = subscription.getStatus(); - if (Boolean.TRUE.equals(subscription.getCancelAtPeriodEnd()) && "active".equals(status)) { - return "canceled"; - } - return status; - } -} +package gg.modl.backend.billing.service; + +import com.stripe.exception.StripeException; +import com.stripe.model.Customer; +import com.stripe.model.Subscription; +import com.stripe.model.SubscriptionItem; +import com.stripe.model.billingportal.Session; +import com.stripe.param.CustomerCreateParams; +import com.stripe.param.CustomerUpdateParams; +import com.stripe.param.SubscriptionCreateParams; +import com.stripe.param.SubscriptionUpdateParams; +import com.stripe.param.billingportal.SessionCreateParams; +import com.stripe.param.checkout.SessionCreateParams.ConsentCollection; +import gg.modl.backend.billing.config.StripeConfiguration; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StripeService { + private final StripeConfiguration config; + private final ModlProperties modlProperties; + + public boolean isConfigured() { + return config.isConfigured(); + } + + public String createCustomer(Server server) throws StripeException { + CustomerCreateParams params = CustomerCreateParams.builder() + .setEmail(server.getAdminEmail()) + .setName(server.getServerName()) + .putMetadata("serverName", server.getCustomDomain()) + .build(); + + Customer customer = Customer.create(params); + return customer.getId(); + } + + public void updateCustomerEmail(String customerId, String newEmail) throws StripeException { + Customer customer = Customer.retrieve(customerId); + customer.update(CustomerUpdateParams.builder().setEmail(newEmail).build()); + } + + public com.stripe.model.checkout.Session createCheckoutSession(String customerId, String subdomain) throws StripeException { + String successUrl = String.format("https://%s.%s/panel/settings?session_id={CHECKOUT_SESSION_ID}", subdomain, modlProperties.getDomain()); + String cancelUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); + + com.stripe.param.checkout.SessionCreateParams params = com.stripe.param.checkout.SessionCreateParams.builder() + .setMode(com.stripe.param.checkout.SessionCreateParams.Mode.SUBSCRIPTION) + .setAllowPromotionCodes(true) + .setConsentCollection( + ConsentCollection.builder() + .setTermsOfService(ConsentCollection.TermsOfService.REQUIRED) + .build() + ) + .addLineItem( + com.stripe.param.checkout.SessionCreateParams.LineItem.builder() + .setPrice(config.getPriceId()) + .setQuantity(1L) + .build() + ) + .setCustomer(customerId) + .setSuccessUrl(successUrl) + .setCancelUrl(cancelUrl) + .build(); + + return com.stripe.model.checkout.Session.create(params); + } + + public Session createPortalSession(String customerId, String subdomain) throws StripeException { + String returnUrl = String.format("https://%s.%s/panel/settings", subdomain, modlProperties.getDomain()); + + SessionCreateParams params = SessionCreateParams.builder() + .setCustomer(customerId) + .setReturnUrl(returnUrl) + .build(); + + return Session.create(params); + } + + public Subscription cancelSubscription(String subscriptionId) throws StripeException { + Subscription subscription = Subscription.retrieve(subscriptionId); + SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() + .setCancelAtPeriodEnd(true) + .build(); + return subscription.update(params); + } + + public Subscription retrieveSubscription(String subscriptionId) throws StripeException { + return Subscription.retrieve(subscriptionId); + } + + public Subscription reactivateSubscription(String subscriptionId) throws StripeException { + Subscription subscription = Subscription.retrieve(subscriptionId); + SubscriptionUpdateParams params = SubscriptionUpdateParams.builder() + .setCancelAtPeriodEnd(false) + .build(); + return subscription.update(params); + } + + public Subscription createSubscription(String customerId) throws StripeException { + SubscriptionCreateParams params = SubscriptionCreateParams.builder() + .setCustomer(customerId) + .addItem( + SubscriptionCreateParams.Item.builder() + .setPrice(config.getPriceId()) + .build() + ) + .build(); + + return Subscription.create(params); + } + + public Date extractPeriodStart(Subscription subscription) { + if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { + SubscriptionItem item = subscription.getItems().getData().get(0); + if (item.getCurrentPeriodStart() != null) { + return new Date(item.getCurrentPeriodStart() * 1000); + } + } + return null; + } + + public Date extractPeriodEnd(Subscription subscription) { + if (subscription.getItems() != null && !subscription.getItems().getData().isEmpty()) { + SubscriptionItem item = subscription.getItems().getData().get(0); + if (item.getCurrentPeriodEnd() != null) { + return new Date(item.getCurrentPeriodEnd() * 1000); + } + } + return null; + } + + public String getEffectiveStatus(Subscription subscription) { + String status = subscription.getStatus(); + if (Boolean.TRUE.equals(subscription.getCancelAtPeriodEnd()) && "active".equals(status)) { + return "canceled"; + } + return status; + } +} diff --git a/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java b/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java index 3a50a98..ec75325 100644 --- a/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java +++ b/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java @@ -1,273 +1,273 @@ -package gg.modl.backend.billing.service; - -import com.stripe.exception.StripeException; -import com.stripe.model.Event; -import com.stripe.model.Invoice; -import com.stripe.model.StripeObject; -import com.stripe.model.Subscription; -import gg.modl.backend.database.mongo.repository.StripeWebhookEventMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerBillingUpdate; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class StripeWebhookService { - private final StripeService stripeService; - private final ServerMongoRepository serverRepository; - private final UsageTrackingService usageTrackingService; - private final ServerMutationHelper serverMutationHelper; - private final StripeWebhookEventMongoRepository webhookEventRepository; - - public void processEvent(Event event) { - if (!webhookEventRepository.markProcessing(event.getId(), event.getType(), new Date())) { - log.info("Ignoring duplicate Stripe webhook event {}", event.getId()); - return; - } - try { - switch (event.getType()) { - case "checkout.session.completed" -> handleCheckoutCompleted(event); - case "customer.subscription.created" -> handleSubscriptionCreated(event); - case "customer.subscription.updated" -> handleSubscriptionUpdated(event); - case "customer.subscription.deleted" -> handleSubscriptionDeleted(event); - case "invoice.payment_failed" -> handlePaymentFailed(event); - case "invoice.payment_succeeded" -> handlePaymentSucceeded(event); - default -> log.debug("Unhandled event type: {}", event.getType()); - } - webhookEventRepository.markProcessed(event.getId(), new Date()); - } catch (RuntimeException exception) { - webhookEventRepository.markFailed(event.getId(), new Date(), exception.getMessage()); - throw exception; - } - } - - private void handleCheckoutCompleted(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof com.stripe.model.checkout.Session session)) { - return; - } - - if (session.getCustomer() == null || session.getSubscription() == null) { - return; - } - - Server server = findServerByCustomerId(session.getCustomer()); - if (server == null) { - log.warn("No server found for customer: {}", session.getCustomer()); - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setStripeSubscriptionId(session.getSubscription()); - current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); - current.setPlan(ServerPlan.PREMIUM); - }); - } - - private void applyPeriodDates(ServerBillingUpdate current, Subscription subscription) { - Date periodStart = stripeService.extractPeriodStart(subscription); - Date periodEnd = stripeService.extractPeriodEnd(subscription); - if (periodStart != null) { - current.setCurrentPeriodStart(periodStart); - } - if (periodEnd != null) { - current.setCurrentPeriodEnd(periodEnd); - } - } - - private Server findServerByCustomerId(String customerId) { - return serverRepository.findByStripeCustomerId(customerId).orElse(null); - } - - private Server resolveServer(Subscription subscription) { - Server server = findServerBySubscriptionId(subscription.getId()); - if (server != null) { - return server; - } - String customerId = subscription.getCustomer(); - if (customerId == null) { - return null; - } - server = findServerByCustomerId(customerId); - if (server != null && server.getStripeSubscriptionId() == null) { - serverMutationHelper.mutate(server, current -> current.setStripeSubscriptionId(subscription.getId())); - } - return server; - } - - private void handleSubscriptionCreated(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - if (subscription.getCustomer() == null) { - return; - } - - Server server = findServerByCustomerId(subscription.getCustomer()); - if (server == null) { - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setStripeSubscriptionId(subscription.getId()); - current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(subscription.getStatus())); - current.setPlan(planForSubscriptionStatus(subscription.getStatus())); - applyPeriodDates(current, subscription); - }); - } - - private ServerPlan planForSubscriptionStatus(String status) { - return isFreeStatus(status) ? ServerPlan.FREE : ServerPlan.PREMIUM; - } - - private boolean isFreeStatus(String status) { - return "past_due".equals(status) - || "unpaid".equals(status) - || "incomplete".equals(status) - || "incomplete_expired".equals(status); - } - - private void handleSubscriptionUpdated(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - Server server = resolveServer(subscription); - if (server == null) { - log.warn("No server found for subscription: {}", subscription.getId()); - return; - } - - String effectiveStatus = stripeService.getEffectiveStatus(subscription); - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(effectiveStatus)); - if (isPremiumStatus(effectiveStatus)) { - current.setPlan(ServerPlan.PREMIUM); - } else if (isFreeStatus(effectiveStatus)) { - current.setPlan(ServerPlan.FREE); - } - - applyPeriodDates(current, subscription); - }); - } - - private Server findServerBySubscriptionId(String subscriptionId) { - return serverRepository.findByStripeSubscriptionId(subscriptionId).orElse(null); - } - - private boolean isPremiumStatus(String status) { - return "active".equals(status) || "trialing".equals(status) || "paused".equals(status); - } - - private void handleSubscriptionDeleted(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - Server server = resolveServer(subscription); - if (server == null) { - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - current.setPlan(ServerPlan.FREE); - current.setCurrentPeriodEnd(null); - }); - usageTrackingService.resetUsageCounters(server.getId()); - } - - private void handlePaymentFailed(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Invoice invoice) || invoice.getCustomer() == null) { - return; - } - - Server server = findServerByCustomerId(invoice.getCustomer()); - if (server == null) { - return; - } - - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.PAST_DUE); - current.setPlan(ServerPlan.FREE); - }); - } - - private void handlePaymentSucceeded(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Invoice invoice) || invoice.getCustomer() == null) { - return; - } - - Server server = findServerByCustomerId(invoice.getCustomer()); - if (server == null) { - return; - } - - String subscriptionId = extractInvoiceSubscriptionId(invoice); - if (subscriptionId == null) { - subscriptionId = server.getStripeSubscriptionId(); - } - - if (subscriptionId == null) { - unstickPastDue(server); - return; - } - - boolean alreadyActive = server.getSubscriptionStatus() == SubscriptionStatus.ACTIVE - && server.getPlan() == ServerPlan.PREMIUM; - if (alreadyActive) { - return; - } - - try { - Subscription subscription = stripeService.retrieveSubscription(subscriptionId); - String effectiveStatus = stripeService.getEffectiveStatus(subscription); - if (isPremiumStatus(effectiveStatus)) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); - current.setPlan(ServerPlan.PREMIUM); - applyPeriodDates(current, subscription); - if (current.getStripeSubscriptionId() == null) { - current.setStripeSubscriptionId(subscription.getId()); - } - }); - } else { - unstickPastDue(server); - } - } catch (StripeException exception) { - throw new ExternalServiceException("Failed to sync subscription state on Stripe payment success", exception); - } - } - - private void unstickPastDue(Server server) { - if (server.getSubscriptionStatus() == SubscriptionStatus.PAST_DUE) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); - current.setPlan(ServerPlan.PREMIUM); - }); - } - } - - private String extractInvoiceSubscriptionId(Invoice invoice) { - Invoice.Parent parent = invoice.getParent(); - if (parent == null || parent.getSubscriptionDetails() == null) { - return null; - } - return parent.getSubscriptionDetails().getSubscription(); - } -} +package gg.modl.backend.billing.service; + +import com.stripe.exception.StripeException; +import com.stripe.model.Event; +import com.stripe.model.Invoice; +import com.stripe.model.StripeObject; +import com.stripe.model.Subscription; +import gg.modl.backend.database.mongo.repository.StripeWebhookEventMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerBillingUpdate; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StripeWebhookService { + private final StripeService stripeService; + private final ServerMongoRepository serverRepository; + private final UsageTrackingService usageTrackingService; + private final ServerMutationHelper serverMutationHelper; + private final StripeWebhookEventMongoRepository webhookEventRepository; + + public void processEvent(Event event) { + if (!webhookEventRepository.markProcessing(event.getId(), event.getType(), new Date())) { + log.info("Ignoring duplicate Stripe webhook event {}", event.getId()); + return; + } + try { + switch (event.getType()) { + case "checkout.session.completed" -> handleCheckoutCompleted(event); + case "customer.subscription.created" -> handleSubscriptionCreated(event); + case "customer.subscription.updated" -> handleSubscriptionUpdated(event); + case "customer.subscription.deleted" -> handleSubscriptionDeleted(event); + case "invoice.payment_failed" -> handlePaymentFailed(event); + case "invoice.payment_succeeded" -> handlePaymentSucceeded(event); + default -> log.debug("Unhandled event type: {}", event.getType()); + } + webhookEventRepository.markProcessed(event.getId(), new Date()); + } catch (RuntimeException exception) { + webhookEventRepository.markFailed(event.getId(), new Date(), exception.getMessage()); + throw exception; + } + } + + private void handleCheckoutCompleted(Event event) { + StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); + if (!(stripeObject instanceof com.stripe.model.checkout.Session session)) { + return; + } + + if (session.getCustomer() == null || session.getSubscription() == null) { + return; + } + + Server server = findServerByCustomerId(session.getCustomer()); + if (server == null) { + log.warn("No server found for customer: {}", session.getCustomer()); + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(session.getSubscription()); + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + }); + } + + private void applyPeriodDates(ServerBillingUpdate current, Subscription subscription) { + Date periodStart = stripeService.extractPeriodStart(subscription); + Date periodEnd = stripeService.extractPeriodEnd(subscription); + if (periodStart != null) { + current.setCurrentPeriodStart(periodStart); + } + if (periodEnd != null) { + current.setCurrentPeriodEnd(periodEnd); + } + } + + private Server findServerByCustomerId(String customerId) { + return serverRepository.findByStripeCustomerId(customerId).orElse(null); + } + + private Server resolveServer(Subscription subscription) { + Server server = findServerBySubscriptionId(subscription.getId()); + if (server != null) { + return server; + } + String customerId = subscription.getCustomer(); + if (customerId == null) { + return null; + } + server = findServerByCustomerId(customerId); + if (server != null && server.getStripeSubscriptionId() == null) { + serverMutationHelper.mutate(server, current -> current.setStripeSubscriptionId(subscription.getId())); + } + return server; + } + + private void handleSubscriptionCreated(Event event) { + StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); + if (!(stripeObject instanceof Subscription subscription)) { + return; + } + + if (subscription.getCustomer() == null) { + return; + } + + Server server = findServerByCustomerId(subscription.getCustomer()); + if (server == null) { + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(subscription.getId()); + current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(subscription.getStatus())); + current.setPlan(planForSubscriptionStatus(subscription.getStatus())); + applyPeriodDates(current, subscription); + }); + } + + private ServerPlan planForSubscriptionStatus(String status) { + return isFreeStatus(status) ? ServerPlan.FREE : ServerPlan.PREMIUM; + } + + private boolean isFreeStatus(String status) { + return "past_due".equals(status) + || "unpaid".equals(status) + || "incomplete".equals(status) + || "incomplete_expired".equals(status); + } + + private void handleSubscriptionUpdated(Event event) { + StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); + if (!(stripeObject instanceof Subscription subscription)) { + return; + } + + Server server = resolveServer(subscription); + if (server == null) { + log.warn("No server found for subscription: {}", subscription.getId()); + return; + } + + String effectiveStatus = stripeService.getEffectiveStatus(subscription); + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(effectiveStatus)); + if (isPremiumStatus(effectiveStatus)) { + current.setPlan(ServerPlan.PREMIUM); + } else if (isFreeStatus(effectiveStatus)) { + current.setPlan(ServerPlan.FREE); + } + + applyPeriodDates(current, subscription); + }); + } + + private Server findServerBySubscriptionId(String subscriptionId) { + return serverRepository.findByStripeSubscriptionId(subscriptionId).orElse(null); + } + + private boolean isPremiumStatus(String status) { + return "active".equals(status) || "trialing".equals(status) || "paused".equals(status); + } + + private void handleSubscriptionDeleted(Event event) { + StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); + if (!(stripeObject instanceof Subscription subscription)) { + return; + } + + Server server = resolveServer(subscription); + if (server == null) { + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + current.setPlan(ServerPlan.FREE); + current.setCurrentPeriodEnd(null); + }); + usageTrackingService.resetUsageCounters(server.getId()); + } + + private void handlePaymentFailed(Event event) { + StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); + if (!(stripeObject instanceof Invoice invoice) || invoice.getCustomer() == null) { + return; + } + + Server server = findServerByCustomerId(invoice.getCustomer()); + if (server == null) { + return; + } + + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.PAST_DUE); + current.setPlan(ServerPlan.FREE); + }); + } + + private void handlePaymentSucceeded(Event event) { + StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); + if (!(stripeObject instanceof Invoice invoice) || invoice.getCustomer() == null) { + return; + } + + Server server = findServerByCustomerId(invoice.getCustomer()); + if (server == null) { + return; + } + + String subscriptionId = extractInvoiceSubscriptionId(invoice); + if (subscriptionId == null) { + subscriptionId = server.getStripeSubscriptionId(); + } + + if (subscriptionId == null) { + unstickPastDue(server); + return; + } + + boolean alreadyActive = server.getSubscriptionStatus() == SubscriptionStatus.ACTIVE + && server.getPlan() == ServerPlan.PREMIUM; + if (alreadyActive) { + return; + } + + try { + Subscription subscription = stripeService.retrieveSubscription(subscriptionId); + String effectiveStatus = stripeService.getEffectiveStatus(subscription); + if (isPremiumStatus(effectiveStatus)) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + applyPeriodDates(current, subscription); + if (current.getStripeSubscriptionId() == null) { + current.setStripeSubscriptionId(subscription.getId()); + } + }); + } else { + unstickPastDue(server); + } + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to sync subscription state on Stripe payment success", exception); + } + } + + private void unstickPastDue(Server server) { + if (server.getSubscriptionStatus() == SubscriptionStatus.PAST_DUE) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + }); + } + } + + private String extractInvoiceSubscriptionId(Invoice invoice) { + Invoice.Parent parent = invoice.getParent(); + if (parent == null || parent.getSubscriptionDetails() == null) { + return null; + } + return parent.getSubscriptionDetails().getSubscription(); + } +} diff --git a/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java b/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java index 3ed27b4..f2e7f4a 100644 --- a/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java +++ b/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java @@ -1,45 +1,45 @@ -package gg.modl.backend.billing.service; - -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SubscriptionExpiryService { - private final ServerMongoRepository serverRepository; - private final UsageTrackingService usageTrackingService; - private final ServerMutationHelper serverMutationHelper; - - @Scheduled(fixedRate = 3600000) - public void checkExpiredSubscriptions() { - try { - List cancelledServers = serverRepository.findCancelledWithPeriodEnd(); - Date now = new Date(); - - for (Server server : cancelledServers) { - Date endDate = server.getCurrentPeriodEnd(); - if (endDate != null && endDate.before(now)) { - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - current.setPlan(ServerPlan.FREE); - current.setCurrentPeriodEnd(null); - }); - usageTrackingService.resetUsageCounters(server.getId()); - } - } - } catch (Exception exception) { - log.error("Error checking for expired subscriptions", exception); - } - } - -} +package gg.modl.backend.billing.service; + +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SubscriptionExpiryService { + private final ServerMongoRepository serverRepository; + private final UsageTrackingService usageTrackingService; + private final ServerMutationHelper serverMutationHelper; + + @Scheduled(fixedRate = 3600000) + public void checkExpiredSubscriptions() { + try { + List cancelledServers = serverRepository.findCancelledWithPeriodEnd(); + Date now = new Date(); + + for (Server server : cancelledServers) { + Date endDate = server.getCurrentPeriodEnd(); + if (endDate != null && endDate.before(now)) { + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + current.setPlan(ServerPlan.FREE); + current.setCurrentPeriodEnd(null); + }); + usageTrackingService.resetUsageCounters(server.getId()); + } + } + } catch (Exception exception) { + log.error("Error checking for expired subscriptions", exception); + } + } + +} diff --git a/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java b/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java index 29cf975..e8463fb 100644 --- a/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java +++ b/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java @@ -1,143 +1,143 @@ -package gg.modl.backend.billing.service; - -import gg.modl.backend.billing.dto.response.UsageBillingSettingsResponse; -import gg.modl.backend.billing.dto.response.UsageResponse; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.storage.service.StorageQuotaService; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class UsageTrackingService { - private final ServerMongoRepository serverRepository; - private final ServerMutationHelper serverMutationHelper; - private final ServerLimitPolicy serverLimitPolicy; - public static final long AI_BASE_LIMIT_REQUESTS = 1000L; - private static final double AI_OVERAGE_RATE = 0.02; - - public UsageResponse getUsage(Server server) { - Server freshServer = getFreshServer(server.getId()); - if (freshServer == null) { - throw new IllegalStateException("Server not found in database."); - } - - Date currentPeriodStart = freshServer.getCurrentPeriodStart(); - if (currentPeriodStart == null) { - currentPeriodStart = new Date(System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)); - } - - Date currentPeriodEnd = freshServer.getCurrentPeriodEnd(); - if (currentPeriodEnd == null) { - currentPeriodEnd = new Date(System.currentTimeMillis() + (30L * 24 * 60 * 60 * 1000)); - } - - long aiRequestsUsed = freshServer.getAiRequestsCurrentPeriod() != null ? freshServer.getAiRequestsCurrentPeriod() : 0L; - boolean usageBillingEnabled = Boolean.TRUE.equals(freshServer.getUsageBillingEnabled()); - - long aiLimitRequests = getAiRequestLimit(freshServer); - long aiOverageRequests = Math.max(0, aiRequestsUsed - getAiBaseLimitRequests()); - double aiOverageCost = usageBillingEnabled ? aiOverageRequests * AI_OVERAGE_RATE : 0.0; - - return new UsageResponse( - new UsageResponse.Period(currentPeriodStart, currentPeriodEnd), - new UsageResponse.UsageMetric( - aiRequestsUsed, - aiLimitRequests, - aiOverageRequests, - AI_OVERAGE_RATE, - aiOverageCost, - Math.min(100, aiLimitRequests > 0 ? ((double) aiRequestsUsed / aiLimitRequests) * 100 : 0) - ), - aiOverageCost, - usageBillingEnabled - ); - } - - public long getAiRequestLimit(Server server) { - return serverLimitPolicy.resolve(server).getAiRequestLimit(); - } - - public long getAiBaseLimitRequests() { - return AI_BASE_LIMIT_REQUESTS; - } - - private Server getFreshServer(String serverId) { - return serverRepository.findById(serverId).orElse(null); - } - - public UsageBillingSettingsResponse updateUsageBillingSettings(Server server, boolean enabled) { - if (enabled && (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank())) { - throw new IllegalStateException("No Stripe customer ID found. Please ensure you have an active subscription."); - } - - serverMutationHelper.mutate(server, current -> { - current.setUsageBillingEnabled(enabled); - current.setUsageBillingUpdatedAt(new Date()); - }); - - String message = enabled - ? "Usage billing has been enabled. You will be charged for overages at the end of each billing period." - : "Usage billing has been disabled. Overages will not be charged."; - - return new UsageBillingSettingsResponse(true, message, enabled); - } - - public void incrementAiRequests(String serverId, long additionalRequests) { - serverRepository.incrementAiRequests(serverId, additionalRequests); - } - - public void resetUsageCounters(String serverId) { - serverRepository.resetUsageCounters(serverId); - } - - public void updateStorageLimit(Server server, long bytes) { - if (server.getPlan() != ServerPlan.PREMIUM) { - throw new ValidationException("Storage limit configuration is only available for premium servers"); - } - if (bytes <= 0) { - throw new ValidationException("Storage limit must be positive"); - } - validatePremiumStorageBytes(bytes); - serverMutationHelper.mutate(server, current -> current.setMaxStorageLimitBytes(bytes)); - } - - public long updateOverageLimits(Server server, long maxStorageOverageGb, long maxAiOverageRequests) { - if (server.getPlan() != ServerPlan.PREMIUM) { - throw new ValidationException("Overage limits configuration is only available for premium servers"); - } - if (maxStorageOverageGb < 0 || maxAiOverageRequests < 0) { - throw new ValidationException("Overage limits cannot be negative"); - } - if (maxStorageOverageGb > StorageQuotaService.MAX_STORAGE_OVERAGE_BYTES / (1024L * 1024 * 1024)) { - throw new ValidationException("Storage overage cannot exceed 2000 GB. Please contact support for higher limits."); - } - if (maxAiOverageRequests > StorageQuotaService.MAX_AI_OVERAGE_REQUESTS) { - throw new ValidationException("AI request overage cannot exceed 5000 requests. Please contact support for higher limits."); - } - - long maxStorageLimitBytes = StorageQuotaService.PREMIUM_BASE_BYTES + maxStorageOverageGb * (1024L * 1024 * 1024); - validatePremiumStorageBytes(maxStorageLimitBytes); - - serverMutationHelper.mutate(server, current -> { - current.setMaxStorageLimitBytes(maxStorageLimitBytes); - current.setMaxAiOverageRequests(maxAiOverageRequests); - }); - return maxStorageLimitBytes; - } - - private void validatePremiumStorageBytes(long bytes) { - if (bytes > StorageQuotaService.MAX_PREMIUM_BYTES) { - throw new ValidationException("Storage limit cannot exceed 2200 GB. Please contact support for higher limits."); - } - } - -} +package gg.modl.backend.billing.service; + +import gg.modl.backend.billing.dto.response.UsageBillingSettingsResponse; +import gg.modl.backend.billing.dto.response.UsageResponse; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.storage.service.StorageQuotaService; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UsageTrackingService { + private final ServerMongoRepository serverRepository; + private final ServerMutationHelper serverMutationHelper; + private final ServerLimitPolicy serverLimitPolicy; + public static final long AI_BASE_LIMIT_REQUESTS = 1000L; + private static final double AI_OVERAGE_RATE = 0.02; + + public UsageResponse getUsage(Server server) { + Server freshServer = getFreshServer(server.getId()); + if (freshServer == null) { + throw new IllegalStateException("Server not found in database."); + } + + Date currentPeriodStart = freshServer.getCurrentPeriodStart(); + if (currentPeriodStart == null) { + currentPeriodStart = new Date(System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)); + } + + Date currentPeriodEnd = freshServer.getCurrentPeriodEnd(); + if (currentPeriodEnd == null) { + currentPeriodEnd = new Date(System.currentTimeMillis() + (30L * 24 * 60 * 60 * 1000)); + } + + long aiRequestsUsed = freshServer.getAiRequestsCurrentPeriod() != null ? freshServer.getAiRequestsCurrentPeriod() : 0L; + boolean usageBillingEnabled = Boolean.TRUE.equals(freshServer.getUsageBillingEnabled()); + + long aiLimitRequests = getAiRequestLimit(freshServer); + long aiOverageRequests = Math.max(0, aiRequestsUsed - getAiBaseLimitRequests()); + double aiOverageCost = usageBillingEnabled ? aiOverageRequests * AI_OVERAGE_RATE : 0.0; + + return new UsageResponse( + new UsageResponse.Period(currentPeriodStart, currentPeriodEnd), + new UsageResponse.UsageMetric( + aiRequestsUsed, + aiLimitRequests, + aiOverageRequests, + AI_OVERAGE_RATE, + aiOverageCost, + Math.min(100, aiLimitRequests > 0 ? ((double) aiRequestsUsed / aiLimitRequests) * 100 : 0) + ), + aiOverageCost, + usageBillingEnabled + ); + } + + public long getAiRequestLimit(Server server) { + return serverLimitPolicy.resolve(server).getAiRequestLimit(); + } + + public long getAiBaseLimitRequests() { + return AI_BASE_LIMIT_REQUESTS; + } + + private Server getFreshServer(String serverId) { + return serverRepository.findById(serverId).orElse(null); + } + + public UsageBillingSettingsResponse updateUsageBillingSettings(Server server, boolean enabled) { + if (enabled && (server.getStripeCustomerId() == null || server.getStripeCustomerId().isBlank())) { + throw new IllegalStateException("No Stripe customer ID found. Please ensure you have an active subscription."); + } + + serverMutationHelper.mutate(server, current -> { + current.setUsageBillingEnabled(enabled); + current.setUsageBillingUpdatedAt(new Date()); + }); + + String message = enabled + ? "Usage billing has been enabled. You will be charged for overages at the end of each billing period." + : "Usage billing has been disabled. Overages will not be charged."; + + return new UsageBillingSettingsResponse(true, message, enabled); + } + + public void incrementAiRequests(String serverId, long additionalRequests) { + serverRepository.incrementAiRequests(serverId, additionalRequests); + } + + public void resetUsageCounters(String serverId) { + serverRepository.resetUsageCounters(serverId); + } + + public void updateStorageLimit(Server server, long bytes) { + if (server.getPlan() != ServerPlan.PREMIUM) { + throw new ValidationException("Storage limit configuration is only available for premium servers"); + } + if (bytes <= 0) { + throw new ValidationException("Storage limit must be positive"); + } + validatePremiumStorageBytes(bytes); + serverMutationHelper.mutate(server, current -> current.setMaxStorageLimitBytes(bytes)); + } + + public long updateOverageLimits(Server server, long maxStorageOverageGb, long maxAiOverageRequests) { + if (server.getPlan() != ServerPlan.PREMIUM) { + throw new ValidationException("Overage limits configuration is only available for premium servers"); + } + if (maxStorageOverageGb < 0 || maxAiOverageRequests < 0) { + throw new ValidationException("Overage limits cannot be negative"); + } + if (maxStorageOverageGb > StorageQuotaService.MAX_STORAGE_OVERAGE_BYTES / (1024L * 1024 * 1024)) { + throw new ValidationException("Storage overage cannot exceed 2000 GB. Please contact support for higher limits."); + } + if (maxAiOverageRequests > StorageQuotaService.MAX_AI_OVERAGE_REQUESTS) { + throw new ValidationException("AI request overage cannot exceed 5000 requests. Please contact support for higher limits."); + } + + long maxStorageLimitBytes = StorageQuotaService.PREMIUM_BASE_BYTES + maxStorageOverageGb * (1024L * 1024 * 1024); + validatePremiumStorageBytes(maxStorageLimitBytes); + + serverMutationHelper.mutate(server, current -> { + current.setMaxStorageLimitBytes(maxStorageLimitBytes); + current.setMaxAiOverageRequests(maxAiOverageRequests); + }); + return maxStorageLimitBytes; + } + + private void validatePremiumStorageBytes(long bytes) { + if (bytes > StorageQuotaService.MAX_PREMIUM_BYTES) { + throw new ValidationException("Storage limit cannot exceed 2200 GB. Please contact support for higher limits."); + } + } + +} diff --git a/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java b/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java index 7f08cb6..b5b5438 100644 --- a/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java +++ b/src/main/java/gg/modl/backend/cloudflare/config/CloudflareConfiguration.java @@ -1,21 +1,21 @@ -package gg.modl.backend.cloudflare.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Configuration -@ConfigurationProperties(prefix = "modl.cloudflare") -@Validated -@Getter -@Setter -public class CloudflareConfiguration { - private String apiToken = ""; - private String zoneId = ""; - - public boolean isConfigured() { - return apiToken != null && !apiToken.isBlank() && zoneId != null && !zoneId.isBlank(); - } -} +package gg.modl.backend.cloudflare.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Configuration +@ConfigurationProperties(prefix = "modl.cloudflare") +@Validated +@Getter +@Setter +public class CloudflareConfiguration { + private String apiToken = ""; + private String zoneId = ""; + + public boolean isConfigured() { + return apiToken != null && !apiToken.isBlank() && zoneId != null && !zoneId.isBlank(); + } +} diff --git a/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java b/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java index 72abe36..6e34423 100644 --- a/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java +++ b/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java @@ -1,201 +1,201 @@ -package gg.modl.backend.cloudflare.external; - -import gg.modl.backend.cloudflare.config.CloudflareConfiguration; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -@Component -@RequiredArgsConstructor -@Slf4j -public class CloudflareClient { - private final CloudflareConfiguration config; - private final RestTemplate restTemplate; - private static final String CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; - - public CustomHostnameResult createCustomHostname(String hostname) { - if (!config.isConfigured()) { - log.warn("Cloudflare is not configured"); - return null; - } - - try { - HttpHeaders headers = createHeaders(); - - Map ssl = Map.of( - "method", "http", - "type", "dv" - ); - - Map body = Map.of( - "hostname", hostname, - "ssl", ssl - ); - - HttpEntity> request = new HttpEntity<>(body, headers); - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames"; - - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Boolean success = (Boolean) response.getBody().get("success"); - if (Boolean.TRUE.equals(success)) { - return parseCustomHostnameResult(response.getBody()); - } else { - List> errors = (List>) response.getBody().get("errors"); - if (errors != null && !errors.isEmpty()) { - log.error("Cloudflare API error creating custom hostname: {}", errors); - } - } - } - return null; - } catch (Exception e) { - log.error("Failed to create custom hostname for {}", hostname, e); - return null; - } - } - - @SuppressWarnings("unchecked") - private CustomHostnameResult parseCustomHostnameResult(Map responseBody) { - Map result = (Map) responseBody.get("result"); - if (result == null) { - return null; - } - - String id = (String) result.get("id"); - String hostname = (String) result.get("hostname"); - String status = (String) result.get("status"); - - CustomHostnameResult.SslStatus sslStatus = null; - Map ssl = (Map) result.get("ssl"); - if (ssl != null) { - sslStatus = new CustomHostnameResult.SslStatus( - (String) ssl.get("status"), - (String) ssl.get("method"), - (String) ssl.get("type") - ); - } - - String ownershipHttpUrl = null; - String ownershipHttpBody = null; - Map ownershipVerification = (Map) result.get("ownership_verification"); - if (ownershipVerification != null) { - ownershipHttpUrl = (String) ownershipVerification.get("http_url"); - ownershipHttpBody = (String) ownershipVerification.get("http_body"); - } - - return new CustomHostnameResult( - id, - hostname, - status, - sslStatus, - ownershipHttpUrl, - ownershipHttpBody - ); - } - - private HttpHeaders createHeaders() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + config.getApiToken()); - return headers; - } - - public CustomHostnameResult getCustomHostname(String hostnameId) { - if (!config.isConfigured()) { - return null; - } - - try { - HttpHeaders headers = createHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Boolean success = (Boolean) response.getBody().get("success"); - if (Boolean.TRUE.equals(success)) { - return parseCustomHostnameResult(response.getBody()); - } - } - return null; - } catch (Exception e) { - log.error("Failed to get custom hostname {}", hostnameId, e); - return null; - } - } - - public CustomHostnameResult findCustomHostnameByName(String hostname) { - if (!config.isConfigured()) { - return null; - } - - try { - HttpHeaders headers = createHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames?hostname=" + hostname; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - Boolean success = (Boolean) response.getBody().get("success"); - if (Boolean.TRUE.equals(success)) { - List> results = (List>) response.getBody().get("result"); - if (results != null && !results.isEmpty()) { - Map modifiedBody = Map.of( - "success", true, - "result", results.get(0) - ); - return parseCustomHostnameResult(modifiedBody); - } - } - } - return null; - } catch (Exception e) { - log.error("Failed to find custom hostname {}", hostname, e); - return null; - } - } - - public boolean deleteCustomHostname(String hostnameId) { - if (!config.isConfigured()) { - return false; - } - - try { - HttpHeaders headers = createHeaders(); - HttpEntity request = new HttpEntity<>(headers); - - String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; - ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, request, Map.class); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - return Boolean.TRUE.equals(response.getBody().get("success")); - } - return false; - } catch (Exception e) { - log.error("Failed to delete custom hostname {}", hostnameId, e); - return false; - } - } - - public record CustomHostnameResult( - String id, - String hostname, - String status, - SslStatus ssl, - String ownershipVerificationHttpUrl, - String ownershipVerificationHttpBody - ) { - public record SslStatus(String status, String method, String type) {} - } -} +package gg.modl.backend.cloudflare.external; + +import gg.modl.backend.cloudflare.config.CloudflareConfiguration; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CloudflareClient { + private final CloudflareConfiguration config; + private final RestTemplate restTemplate; + private static final String CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; + + public CustomHostnameResult createCustomHostname(String hostname) { + if (!config.isConfigured()) { + log.warn("Cloudflare is not configured"); + return null; + } + + try { + HttpHeaders headers = createHeaders(); + + Map ssl = Map.of( + "method", "http", + "type", "dv" + ); + + Map body = Map.of( + "hostname", hostname, + "ssl", ssl + ); + + HttpEntity> request = new HttpEntity<>(body, headers); + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames"; + + ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Boolean success = (Boolean) response.getBody().get("success"); + if (Boolean.TRUE.equals(success)) { + return parseCustomHostnameResult(response.getBody()); + } else { + List> errors = (List>) response.getBody().get("errors"); + if (errors != null && !errors.isEmpty()) { + log.error("Cloudflare API error creating custom hostname: {}", errors); + } + } + } + return null; + } catch (Exception e) { + log.error("Failed to create custom hostname for {}", hostname, e); + return null; + } + } + + @SuppressWarnings("unchecked") + private CustomHostnameResult parseCustomHostnameResult(Map responseBody) { + Map result = (Map) responseBody.get("result"); + if (result == null) { + return null; + } + + String id = (String) result.get("id"); + String hostname = (String) result.get("hostname"); + String status = (String) result.get("status"); + + CustomHostnameResult.SslStatus sslStatus = null; + Map ssl = (Map) result.get("ssl"); + if (ssl != null) { + sslStatus = new CustomHostnameResult.SslStatus( + (String) ssl.get("status"), + (String) ssl.get("method"), + (String) ssl.get("type") + ); + } + + String ownershipHttpUrl = null; + String ownershipHttpBody = null; + Map ownershipVerification = (Map) result.get("ownership_verification"); + if (ownershipVerification != null) { + ownershipHttpUrl = (String) ownershipVerification.get("http_url"); + ownershipHttpBody = (String) ownershipVerification.get("http_body"); + } + + return new CustomHostnameResult( + id, + hostname, + status, + sslStatus, + ownershipHttpUrl, + ownershipHttpBody + ); + } + + private HttpHeaders createHeaders() { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + config.getApiToken()); + return headers; + } + + public CustomHostnameResult getCustomHostname(String hostnameId) { + if (!config.isConfigured()) { + return null; + } + + try { + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Boolean success = (Boolean) response.getBody().get("success"); + if (Boolean.TRUE.equals(success)) { + return parseCustomHostnameResult(response.getBody()); + } + } + return null; + } catch (Exception e) { + log.error("Failed to get custom hostname {}", hostnameId, e); + return null; + } + } + + public CustomHostnameResult findCustomHostnameByName(String hostname) { + if (!config.isConfigured()) { + return null; + } + + try { + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames?hostname=" + hostname; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + Boolean success = (Boolean) response.getBody().get("success"); + if (Boolean.TRUE.equals(success)) { + List> results = (List>) response.getBody().get("result"); + if (results != null && !results.isEmpty()) { + Map modifiedBody = Map.of( + "success", true, + "result", results.get(0) + ); + return parseCustomHostnameResult(modifiedBody); + } + } + } + return null; + } catch (Exception e) { + log.error("Failed to find custom hostname {}", hostname, e); + return null; + } + } + + public boolean deleteCustomHostname(String hostnameId) { + if (!config.isConfigured()) { + return false; + } + + try { + HttpHeaders headers = createHeaders(); + HttpEntity request = new HttpEntity<>(headers); + + String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames/" + hostnameId; + ResponseEntity response = restTemplate.exchange(url, HttpMethod.DELETE, request, Map.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + return Boolean.TRUE.equals(response.getBody().get("success")); + } + return false; + } catch (Exception e) { + log.error("Failed to delete custom hostname {}", hostnameId, e); + return false; + } + } + + public record CustomHostnameResult( + String id, + String hostname, + String status, + SslStatus ssl, + String ownershipVerificationHttpUrl, + String ownershipVerificationHttpBody + ) { + public record SslStatus(String status, String method, String type) {} + } +} diff --git a/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java b/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java index 3ea91ae..46a53ed 100644 --- a/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java +++ b/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java @@ -1,83 +1,83 @@ -package gg.modl.backend.dashboard.controller; - -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; -import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; -import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; -import gg.modl.backend.dashboard.service.DashboardService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.proto.modl.v1.DashboardActivityResponse; -import gg.modl.proto.modl.v1.DashboardRecentPunishmentsResponse; -import gg.modl.proto.modl.v1.DashboardRecentTicketsResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_DASHBOARD) -@RequiredArgsConstructor -@Validated -public class DashboardController { - private final DashboardService dashboardService; - - @GetMapping("/metrics") - public ResponseEntity getMetrics( - @RequestParam(name = "period", defaultValue = "7d") String period, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - DashboardMetricsResponse metrics = dashboardService.getMetrics(server, period); - return ResponseEntity.ok(DashboardProtoMapper.toMetrics(metrics)); - } - - @GetMapping("/recent-tickets") - public ResponseEntity getRecentTickets( - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List tickets = dashboardService.getRecentTickets(server, limit); - return ResponseEntity.ok(DashboardProtoMapper.toRecentTickets(tickets)); - } - - @GetMapping("/recent-punishments") - public ResponseEntity getRecentPunishments( - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = dashboardService.getRecentPunishments(server, limit); - return ResponseEntity.ok(DashboardProtoMapper.toRecentPunishments(punishments)); - } - - @GetMapping("/activity/recent") - public ResponseEntity getRecentActivity( - @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(defaultValue = "7") @Min(1) @Max(DashboardService.MAX_DAYS) int days, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - AuthSessionData session = RequestUtil.getSession(request); - - if (session == null || session.getEmail() == null) { - throw new UnauthorizedException("Not authenticated"); - } - - String staffEmail = session.getEmail(); - List activities = dashboardService.getRecentActivity(server, staffEmail, limit, days); - return ResponseEntity.ok(DashboardProtoMapper.toActivity(activities)); - } -} +package gg.modl.backend.dashboard.controller; + +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; +import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; +import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; +import gg.modl.backend.dashboard.service.DashboardService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.DashboardActivityResponse; +import gg.modl.proto.modl.v1.DashboardRecentPunishmentsResponse; +import gg.modl.proto.modl.v1.DashboardRecentTicketsResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_DASHBOARD) +@RequiredArgsConstructor +@Validated +public class DashboardController { + private final DashboardService dashboardService; + + @GetMapping("/metrics") + public ResponseEntity getMetrics( + @RequestParam(name = "period", defaultValue = "7d") String period, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + DashboardMetricsResponse metrics = dashboardService.getMetrics(server, period); + return ResponseEntity.ok(DashboardProtoMapper.toMetrics(metrics)); + } + + @GetMapping("/recent-tickets") + public ResponseEntity getRecentTickets( + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List tickets = dashboardService.getRecentTickets(server, limit); + return ResponseEntity.ok(DashboardProtoMapper.toRecentTickets(tickets)); + } + + @GetMapping("/recent-punishments") + public ResponseEntity getRecentPunishments( + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = dashboardService.getRecentPunishments(server, limit); + return ResponseEntity.ok(DashboardProtoMapper.toRecentPunishments(punishments)); + } + + @GetMapping("/activity/recent") + public ResponseEntity getRecentActivity( + @RequestParam(defaultValue = "20") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(defaultValue = "7") @Min(1) @Max(DashboardService.MAX_DAYS) int days, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + AuthSessionData session = RequestUtil.getSession(request); + + if (session == null || session.getEmail() == null) { + throw new UnauthorizedException("Not authenticated"); + } + + String staffEmail = session.getEmail(); + List activities = dashboardService.getRecentActivity(server, staffEmail, limit, days); + return ResponseEntity.ok(DashboardProtoMapper.toActivity(activities)); + } +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java index c0101c1..57755ed 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/ActivityItemResponse.java @@ -1,21 +1,21 @@ -package gg.modl.backend.dashboard.dto.response; - -import java.util.Date; -import java.util.List; - -public record ActivityItemResponse( - String id, - String type, - String color, - String title, - Date time, - String description, - List actions -) { - public record ActivityAction( - String label, - String link, - boolean primary - ) { - } -} +package gg.modl.backend.dashboard.dto.response; + +import java.util.Date; +import java.util.List; + +public record ActivityItemResponse( + String id, + String type, + String color, + String title, + Date time, + String description, + List actions +) { + public record ActivityAction( + String label, + String link, + boolean primary + ) { + } +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java index 5614440..096fb9d 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/DashboardMetricsResponse.java @@ -1,13 +1,13 @@ -package gg.modl.backend.dashboard.dto.response; - -public record DashboardMetricsResponse( - long totalTickets, - long openTickets, - long totalPlayers, - long totalPunishments, - long activePunishments, - long totalStaff, - int ticketsTrend, - int playersTrend -) { -} +package gg.modl.backend.dashboard.dto.response; + +public record DashboardMetricsResponse( + long totalTickets, + long openTickets, + long totalPlayers, + long totalPunishments, + long activePunishments, + long totalStaff, + int ticketsTrend, + int playersTrend +) { +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java index 9a32a2d..9e7c5e9 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentPunishmentResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.dashboard.dto.response; - -import java.util.Date; - -public record RecentPunishmentResponse( - String id, - String playerName, - String playerUuid, - String type, - String reason, - String issuerName, - Date issued, - boolean active -) { -} +package gg.modl.backend.dashboard.dto.response; + +import java.util.Date; + +public record RecentPunishmentResponse( + String id, + String playerName, + String playerUuid, + String type, + String reason, + String issuerName, + Date issued, + boolean active +) { +} diff --git a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java index 75fb980..ceccbc7 100644 --- a/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java +++ b/src/main/java/gg/modl/backend/dashboard/dto/response/RecentTicketResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.dashboard.dto.response; - -import java.util.Date; - -public record RecentTicketResponse( - String id, - String title, - String initialMessage, - String status, - String priority, - Date createdAt, - String playerName, - String type -) { -} +package gg.modl.backend.dashboard.dto.response; + +import java.util.Date; + +public record RecentTicketResponse( + String id, + String title, + String initialMessage, + String status, + String priority, + Date createdAt, + String playerName, + String type +) { +} diff --git a/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java b/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java index e121841..c81f83c 100644 --- a/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java +++ b/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java @@ -1,388 +1,388 @@ -package gg.modl.backend.dashboard.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; -import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; -import gg.modl.backend.dashboard.dto.response.MinecraftDashboardStatsResponse; -import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; -import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; -import gg.modl.backend.database.mongo.fields.PlayerFields; -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.EnforcementCategory; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.service.PlayerStatusCalculator; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeIndex; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketPriority; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import gg.modl.backend.ticket.util.TicketAssigneeUtil; -import gg.modl.backend.infrastructure.util.DateRangeUtil; -import gg.modl.backend.player.service.PlayerDataUtils; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.bson.Document; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class DashboardService { - private final TicketMongoRepository ticketRepository; - private final PlayerMongoRepository playerRepository; - private final PunishmentMongoRepository punishmentRepository; - private final StaffMongoRepository staffRepository; - private final StaffService staffService; - private final PunishmentTypeService punishmentTypeService; - private final PlayerStatusCalculator statusCalculator; - - private final Cache activePunishmentCountsCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(60)) - .maximumSize(500) - .build(); - - private static final int RECENT_PUNISHMENT_WINDOW_DAYS = 7; - private static final int MAX_RECENT_TICKETS_LIMIT = 20; - private static final int MAX_RECENT_PUNISHMENTS_LIMIT = 20; - private static final int MAX_ACTIVITY_LIMIT = 100; - public static final int MAX_DAYS = 90; - private static final int MAX_QUERY_RESULTS = 200; - - public MinecraftDashboardStatsResponse getMinecraftStats(Server server) { - long unresolvedReports = ticketRepository.countUnresolvedReports(server); - long unresolvedTickets = ticketRepository.countUnresolvedTickets(server); - long onlineStaff = countActiveStaff(server); - long onlinePlayers = playerRepository.countOnlinePlayers(server); - long totalPlayers = playerRepository.countAll(server); - - ActivePunishmentCounts punishmentCounts = countActivePunishments(server); - - return new MinecraftDashboardStatsResponse( - unresolvedReports, - unresolvedTickets, - onlineStaff, - onlinePlayers, - punishmentCounts.bans, - punishmentCounts.mutes, - punishmentCounts.total, - totalPlayers - ); - } - - private long countActiveStaff(Server server) { - List assignedUuids = staffRepository.findAssignedMinecraftUuids(server); - if (assignedUuids.isEmpty()) { - return 0; - } - return playerRepository.countOnlineByUuids(server, assignedUuids); - } - - private ActivePunishmentCounts countActivePunishments(Server server) { - return activePunishmentCountsCache.get(server.getId(), key -> computeActivePunishments(server)); - } - - private ActivePunishmentCounts computeActivePunishments(Server server) { - Map punishmentTypesByOrdinal = buildPunishmentTypeByOrdinal(server); - long activeBans = 0; - long activeMutes = 0; - long totalPunishments = 0; - - for (Player player : punishmentRepository.findWithPunishmentsProjected(server)) { - if (player.getPunishments().isEmpty()) { - continue; - } - - for (Punishment punishment : player.getPunishments()) { - if (!isPunishmentActiveSafely(punishment)) { - continue; - } - - totalPunishments++; - PunishmentType punishmentType = punishmentTypesByOrdinal.get(punishment.getTypeOrdinal()); - if (punishmentType == null) { - continue; - } - - String category = statusCalculator.getEffectiveCategory(punishment, punishmentTypesByOrdinal); - if (EnforcementCategory.BAN.name().equals(category)) { - activeBans++; - } else if (EnforcementCategory.MUTE.name().equals(category)) { - activeMutes++; - } - } - } - - return new ActivePunishmentCounts(activeBans, activeMutes, totalPunishments); - } - - private Map buildPunishmentTypeByOrdinal(Server server) { - return PunishmentTypeIndex.byOrdinal(punishmentTypeService.getPunishmentTypes(server)); - } - - private boolean isPunishmentActiveSafely(Punishment punishment) { - try { - return statusCalculator.isPunishmentActive(punishment); - } catch (Exception exception) { - log.warn("Failed to calculate punishment active state for punishment id={}", punishment.getId(), exception); - return false; - } - } - - public DashboardMetricsResponse getMetrics(Server server, String period) { - int windowDays = DateRangeUtil.resolveRangeDays(period); - Date windowStart = DateRangeUtil.daysAgo(windowDays); - Date priorWindowStart = DateRangeUtil.daysAgo(windowDays * 2); - - long totalTickets = ticketRepository.countAll(server); - long openTickets = ticketRepository.countByStatus(server, TicketStatus.OPEN); - long totalPlayers = playerRepository.countAll(server); - long totalStaff = staffService.countStaffIncludingSuperAdmin(server); - - long activePunishments = countActivePunishments(server).total(); - long totalPunishments = punishmentRepository.countAllPunishments(server); - - long recentTickets = ticketRepository.countCreatedAfter(server, windowStart); - long prevTickets = ticketRepository.countCreatedBetween(server, priorWindowStart, windowStart); - int ticketsTrend = prevTickets > 0 ? (int) Math.round(((double) (recentTickets - prevTickets) / prevTickets) * 100) : 0; - - long recentPlayers = playerRepository.countFirstJoinedAfter(server, windowStart); - long prevPlayers = playerRepository.countFirstJoinedBetween(server, priorWindowStart, windowStart); - int playersTrend = prevPlayers > 0 ? (int) Math.round(((double) (recentPlayers - prevPlayers) / prevPlayers) * 100) : 0; - - return new DashboardMetricsResponse( - totalTickets, - openTickets, - totalPlayers, - totalPunishments, - activePunishments, - totalStaff, - ticketsTrend, - playersTrend - ); - } - - public List getRecentTickets(Server server, int limit) { - int safeLimit = clampLimit(limit, MAX_RECENT_TICKETS_LIMIT); - List tickets = ticketRepository.findRecentWithProjection(server, safeLimit); - - return tickets.stream() - .map(ticket -> { - String initialMessage = null; - if (ticket.getReplies() != null && !ticket.getReplies().isEmpty()) { - TicketReply firstReply = ticket.getReplies().get(0); - if (firstReply.getContent() != null) { - initialMessage = firstReply.getContent(); - } - } - - return new RecentTicketResponse( - ticket.getId(), - ticket.getSubject() != null ? ticket.getSubject() : "No Subject", - initialMessage, - ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), - ticket.getPriority() != null ? ticket.getPriority().getId() : TicketPriority.NORMAL.getId(), - ticket.getCreated(), - ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unknown", - ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId() - ); - }) - .toList(); - } - - private int clampLimit(int value, int max) { - return Math.max(1, Math.min(value, max)); - } - - public List getRecentPunishments(Server server, int limit) { - int safeLimit = clampLimit(limit, MAX_RECENT_PUNISHMENTS_LIMIT); - Date cutoff = DateRangeUtil.daysAgo(RECENT_PUNISHMENT_WINDOW_DAYS); - - Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); - List punishmentRows = punishmentRepository.fetchRecentPunishmentRows(server, cutoff, safeLimit); - - List results = new ArrayList<>(); - for (Document row : punishmentRows) { - Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); - if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoff)) { - continue; - } - - String reason = ""; - if (punishment.getData() != null && punishment.getData().get("reason") != null) { - reason = String.valueOf(punishment.getData().get("reason")); - } - - String typeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); - String playerName = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); - String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); - - results.add(new RecentPunishmentResponse( - punishment.getId(), - playerName, - playerUuid, - typeName, - reason, - punishment.getIssuerName() != null ? punishment.getIssuerName() : "Unknown", - punishment.getIssued(), - isPunishmentActiveSafely(punishment) - )); - } - - if (results.size() > safeLimit) { - return results.subList(0, safeLimit); - } - - return results; - } - - private Punishment readPunishment(Server server, Document punishmentDocument) { - if (punishmentDocument == null) { - return null; - } - - try { - return punishmentRepository.readPunishment(server, punishmentDocument); - } catch (Exception exception) { - log.warn("Failed to parse punishment document for dashboard response", exception); - return null; - } - } - - private Map buildPunishmentTypeNameByOrdinal(Server server) { - Map names = new HashMap<>(); - buildPunishmentTypeByOrdinal(server).forEach((ordinal, type) -> - names.put(ordinal, type.getName())); - return names; - } - - - public List getRecentActivity(Server server, String staffEmail, int limit, int days) { - List activities = new ArrayList<>(); - - int safeLimit = clampLimit(limit, MAX_ACTIVITY_LIMIT); - int safeDays = clampLimit(days, MAX_DAYS); - - String staffUsername = staffRepository.findUsernameByEmail(server, staffEmail).orElse(null); - if (staffUsername == null) { - return activities; - } - - Date cutoffDate = DateRangeUtil.daysAgo(safeDays); - - fetchTicketActivities(server, staffUsername, cutoffDate, activities); - fetchPunishmentActivities(server, staffUsername, cutoffDate, activities); - - activities.sort((left, right) -> right.time().compareTo(left.time())); - - if (activities.size() > safeLimit) { - return activities.subList(0, safeLimit); - } - - return activities; - } - - private void fetchTicketActivities(Server server, String staffUsername, Date cutoffDate, List activities) { - try { - String normalizedStaffUsername = TicketAssigneeUtil.normalizeSingle(staffUsername); - List tickets = ticketRepository.findStaffActivityTickets(server, staffUsername, normalizedStaffUsername, cutoffDate, MAX_QUERY_RESULTS); - - for (Ticket ticket : tickets) { - if (ticket.getCreatorName() != null - && ticket.getCreatorName().equals(staffUsername) - && ticket.getCreated() != null - && ticket.getCreated().after(cutoffDate)) { - activities.add(new ActivityItemResponse( - "ticket-created-" + ticket.getId(), - "new_ticket", - "blue", - "Created ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), - ticket.getCreated(), - "Created " + displayCategory(ticket) + " ticket", - List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) - )); - } - - if (ticket.getReplies() != null) { - for (TicketReply reply : ticket.getReplies()) { - if (reply.getCreated() == null || !reply.getCreated().after(cutoffDate)) { - continue; - } - - boolean isStaffReply = staffUsername.equalsIgnoreCase(reply.getName()); - String actionType = isStaffReply ? "My reply" : "New reply"; - String color = isStaffReply ? "green" : "blue"; - String replyName = reply.getName() != null ? reply.getName() : "Unknown"; - String description = isStaffReply - ? "You replied to " + displayCategory(ticket) + " ticket" - : replyName + " replied to " + displayCategory(ticket) + " ticket"; - - activities.add(new ActivityItemResponse( - "ticket-reply-" + ticket.getId() + "-" + reply.getCreated().getTime(), - "mod_action", - color, - actionType + " on ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), - reply.getCreated(), - description, - List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) - )); - } - } - } - } catch (Exception exception) { - log.error("Error fetching ticket activities", exception); - } - } - - private String displayCategory(Ticket ticket) { - return ticket.getType() != null ? ticket.getType().getDisplayName() : TicketCategory.SUPPORT.getDisplayName(); - } - - private void fetchPunishmentActivities(Server server, String staffUsername, Date cutoffDate, List activities) { - try { - Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); - List punishmentRows = punishmentRepository.fetchRecentPunishmentRowsByIssuer(server, staffUsername, cutoffDate, MAX_QUERY_RESULTS); - - for (Document row : punishmentRows) { - Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); - if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoffDate)) { - continue; - } - - - String username = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); - String punishmentTypeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); - String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); - - activities.add(new ActivityItemResponse( - "punishment-" + punishment.getId(), - "new_punishment", - "red", - "Applied " + punishmentTypeName + " to " + username, - punishment.getIssued(), - "Applied " + punishmentTypeName + " punishment", - List.of(new ActivityItemResponse.ActivityAction("View Player", "/panel/players/" + playerUuid, true)) - )); - } - } catch (Exception exception) { - log.error("Error fetching punishment activities", exception); - } - } - - private record ActivePunishmentCounts(long bans, long mutes, long total) {} -} +package gg.modl.backend.dashboard.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.dashboard.dto.response.ActivityItemResponse; +import gg.modl.backend.dashboard.dto.response.DashboardMetricsResponse; +import gg.modl.backend.dashboard.dto.response.MinecraftDashboardStatsResponse; +import gg.modl.backend.dashboard.dto.response.RecentPunishmentResponse; +import gg.modl.backend.dashboard.dto.response.RecentTicketResponse; +import gg.modl.backend.database.mongo.fields.PlayerFields; +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.EnforcementCategory; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.service.PlayerStatusCalculator; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeIndex; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketPriority; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.ticket.util.TicketAssigneeUtil; +import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.player.service.PlayerDataUtils; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DashboardService { + private final TicketMongoRepository ticketRepository; + private final PlayerMongoRepository playerRepository; + private final PunishmentMongoRepository punishmentRepository; + private final StaffMongoRepository staffRepository; + private final StaffService staffService; + private final PunishmentTypeService punishmentTypeService; + private final PlayerStatusCalculator statusCalculator; + + private final Cache activePunishmentCountsCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(500) + .build(); + + private static final int RECENT_PUNISHMENT_WINDOW_DAYS = 7; + private static final int MAX_RECENT_TICKETS_LIMIT = 20; + private static final int MAX_RECENT_PUNISHMENTS_LIMIT = 20; + private static final int MAX_ACTIVITY_LIMIT = 100; + public static final int MAX_DAYS = 90; + private static final int MAX_QUERY_RESULTS = 200; + + public MinecraftDashboardStatsResponse getMinecraftStats(Server server) { + long unresolvedReports = ticketRepository.countUnresolvedReports(server); + long unresolvedTickets = ticketRepository.countUnresolvedTickets(server); + long onlineStaff = countActiveStaff(server); + long onlinePlayers = playerRepository.countOnlinePlayers(server); + long totalPlayers = playerRepository.countAll(server); + + ActivePunishmentCounts punishmentCounts = countActivePunishments(server); + + return new MinecraftDashboardStatsResponse( + unresolvedReports, + unresolvedTickets, + onlineStaff, + onlinePlayers, + punishmentCounts.bans, + punishmentCounts.mutes, + punishmentCounts.total, + totalPlayers + ); + } + + private long countActiveStaff(Server server) { + List assignedUuids = staffRepository.findAssignedMinecraftUuids(server); + if (assignedUuids.isEmpty()) { + return 0; + } + return playerRepository.countOnlineByUuids(server, assignedUuids); + } + + private ActivePunishmentCounts countActivePunishments(Server server) { + return activePunishmentCountsCache.get(server.getId(), key -> computeActivePunishments(server)); + } + + private ActivePunishmentCounts computeActivePunishments(Server server) { + Map punishmentTypesByOrdinal = buildPunishmentTypeByOrdinal(server); + long activeBans = 0; + long activeMutes = 0; + long totalPunishments = 0; + + for (Player player : punishmentRepository.findWithPunishmentsProjected(server)) { + if (player.getPunishments().isEmpty()) { + continue; + } + + for (Punishment punishment : player.getPunishments()) { + if (!isPunishmentActiveSafely(punishment)) { + continue; + } + + totalPunishments++; + PunishmentType punishmentType = punishmentTypesByOrdinal.get(punishment.getTypeOrdinal()); + if (punishmentType == null) { + continue; + } + + String category = statusCalculator.getEffectiveCategory(punishment, punishmentTypesByOrdinal); + if (EnforcementCategory.BAN.name().equals(category)) { + activeBans++; + } else if (EnforcementCategory.MUTE.name().equals(category)) { + activeMutes++; + } + } + } + + return new ActivePunishmentCounts(activeBans, activeMutes, totalPunishments); + } + + private Map buildPunishmentTypeByOrdinal(Server server) { + return PunishmentTypeIndex.byOrdinal(punishmentTypeService.getPunishmentTypes(server)); + } + + private boolean isPunishmentActiveSafely(Punishment punishment) { + try { + return statusCalculator.isPunishmentActive(punishment); + } catch (Exception exception) { + log.warn("Failed to calculate punishment active state for punishment id={}", punishment.getId(), exception); + return false; + } + } + + public DashboardMetricsResponse getMetrics(Server server, String period) { + int windowDays = DateRangeUtil.resolveRangeDays(period); + Date windowStart = DateRangeUtil.daysAgo(windowDays); + Date priorWindowStart = DateRangeUtil.daysAgo(windowDays * 2); + + long totalTickets = ticketRepository.countAll(server); + long openTickets = ticketRepository.countByStatus(server, TicketStatus.OPEN); + long totalPlayers = playerRepository.countAll(server); + long totalStaff = staffService.countStaffIncludingSuperAdmin(server); + + long activePunishments = countActivePunishments(server).total(); + long totalPunishments = punishmentRepository.countAllPunishments(server); + + long recentTickets = ticketRepository.countCreatedAfter(server, windowStart); + long prevTickets = ticketRepository.countCreatedBetween(server, priorWindowStart, windowStart); + int ticketsTrend = prevTickets > 0 ? (int) Math.round(((double) (recentTickets - prevTickets) / prevTickets) * 100) : 0; + + long recentPlayers = playerRepository.countFirstJoinedAfter(server, windowStart); + long prevPlayers = playerRepository.countFirstJoinedBetween(server, priorWindowStart, windowStart); + int playersTrend = prevPlayers > 0 ? (int) Math.round(((double) (recentPlayers - prevPlayers) / prevPlayers) * 100) : 0; + + return new DashboardMetricsResponse( + totalTickets, + openTickets, + totalPlayers, + totalPunishments, + activePunishments, + totalStaff, + ticketsTrend, + playersTrend + ); + } + + public List getRecentTickets(Server server, int limit) { + int safeLimit = clampLimit(limit, MAX_RECENT_TICKETS_LIMIT); + List tickets = ticketRepository.findRecentWithProjection(server, safeLimit); + + return tickets.stream() + .map(ticket -> { + String initialMessage = null; + if (ticket.getReplies() != null && !ticket.getReplies().isEmpty()) { + TicketReply firstReply = ticket.getReplies().get(0); + if (firstReply.getContent() != null) { + initialMessage = firstReply.getContent(); + } + } + + return new RecentTicketResponse( + ticket.getId(), + ticket.getSubject() != null ? ticket.getSubject() : "No Subject", + initialMessage, + ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), + ticket.getPriority() != null ? ticket.getPriority().getId() : TicketPriority.NORMAL.getId(), + ticket.getCreated(), + ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unknown", + ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId() + ); + }) + .toList(); + } + + private int clampLimit(int value, int max) { + return Math.max(1, Math.min(value, max)); + } + + public List getRecentPunishments(Server server, int limit) { + int safeLimit = clampLimit(limit, MAX_RECENT_PUNISHMENTS_LIMIT); + Date cutoff = DateRangeUtil.daysAgo(RECENT_PUNISHMENT_WINDOW_DAYS); + + Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); + List punishmentRows = punishmentRepository.fetchRecentPunishmentRows(server, cutoff, safeLimit); + + List results = new ArrayList<>(); + for (Document row : punishmentRows) { + Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); + if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoff)) { + continue; + } + + String reason = ""; + if (punishment.getData() != null && punishment.getData().get("reason") != null) { + reason = String.valueOf(punishment.getData().get("reason")); + } + + String typeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); + String playerName = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); + String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); + + results.add(new RecentPunishmentResponse( + punishment.getId(), + playerName, + playerUuid, + typeName, + reason, + punishment.getIssuerName() != null ? punishment.getIssuerName() : "Unknown", + punishment.getIssued(), + isPunishmentActiveSafely(punishment) + )); + } + + if (results.size() > safeLimit) { + return results.subList(0, safeLimit); + } + + return results; + } + + private Punishment readPunishment(Server server, Document punishmentDocument) { + if (punishmentDocument == null) { + return null; + } + + try { + return punishmentRepository.readPunishment(server, punishmentDocument); + } catch (Exception exception) { + log.warn("Failed to parse punishment document for dashboard response", exception); + return null; + } + } + + private Map buildPunishmentTypeNameByOrdinal(Server server) { + Map names = new HashMap<>(); + buildPunishmentTypeByOrdinal(server).forEach((ordinal, type) -> + names.put(ordinal, type.getName())); + return names; + } + + + public List getRecentActivity(Server server, String staffEmail, int limit, int days) { + List activities = new ArrayList<>(); + + int safeLimit = clampLimit(limit, MAX_ACTIVITY_LIMIT); + int safeDays = clampLimit(days, MAX_DAYS); + + String staffUsername = staffRepository.findUsernameByEmail(server, staffEmail).orElse(null); + if (staffUsername == null) { + return activities; + } + + Date cutoffDate = DateRangeUtil.daysAgo(safeDays); + + fetchTicketActivities(server, staffUsername, cutoffDate, activities); + fetchPunishmentActivities(server, staffUsername, cutoffDate, activities); + + activities.sort((left, right) -> right.time().compareTo(left.time())); + + if (activities.size() > safeLimit) { + return activities.subList(0, safeLimit); + } + + return activities; + } + + private void fetchTicketActivities(Server server, String staffUsername, Date cutoffDate, List activities) { + try { + String normalizedStaffUsername = TicketAssigneeUtil.normalizeSingle(staffUsername); + List tickets = ticketRepository.findStaffActivityTickets(server, staffUsername, normalizedStaffUsername, cutoffDate, MAX_QUERY_RESULTS); + + for (Ticket ticket : tickets) { + if (ticket.getCreatorName() != null + && ticket.getCreatorName().equals(staffUsername) + && ticket.getCreated() != null + && ticket.getCreated().after(cutoffDate)) { + activities.add(new ActivityItemResponse( + "ticket-created-" + ticket.getId(), + "new_ticket", + "blue", + "Created ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), + ticket.getCreated(), + "Created " + displayCategory(ticket) + " ticket", + List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) + )); + } + + if (ticket.getReplies() != null) { + for (TicketReply reply : ticket.getReplies()) { + if (reply.getCreated() == null || !reply.getCreated().after(cutoffDate)) { + continue; + } + + boolean isStaffReply = staffUsername.equalsIgnoreCase(reply.getName()); + String actionType = isStaffReply ? "My reply" : "New reply"; + String color = isStaffReply ? "green" : "blue"; + String replyName = reply.getName() != null ? reply.getName() : "Unknown"; + String description = isStaffReply + ? "You replied to " + displayCategory(ticket) + " ticket" + : replyName + " replied to " + displayCategory(ticket) + " ticket"; + + activities.add(new ActivityItemResponse( + "ticket-reply-" + ticket.getId() + "-" + reply.getCreated().getTime(), + "mod_action", + color, + actionType + " on ticket: " + (ticket.getSubject() != null ? ticket.getSubject() : "No Subject"), + reply.getCreated(), + description, + List.of(new ActivityItemResponse.ActivityAction("View Ticket", "/panel/tickets/" + ticket.getId(), true)) + )); + } + } + } + } catch (Exception exception) { + log.error("Error fetching ticket activities", exception); + } + } + + private String displayCategory(Ticket ticket) { + return ticket.getType() != null ? ticket.getType().getDisplayName() : TicketCategory.SUPPORT.getDisplayName(); + } + + private void fetchPunishmentActivities(Server server, String staffUsername, Date cutoffDate, List activities) { + try { + Map punishmentTypeNameByOrdinal = buildPunishmentTypeNameByOrdinal(server); + List punishmentRows = punishmentRepository.fetchRecentPunishmentRowsByIssuer(server, staffUsername, cutoffDate, MAX_QUERY_RESULTS); + + for (Document row : punishmentRows) { + Punishment punishment = readPunishment(server, row.get("punishment", Document.class)); + if (punishment == null || punishment.getIssued() == null || punishment.getIssued().before(cutoffDate)) { + continue; + } + + + String username = PlayerDataUtils.extractLatestUsername(row.get(PlayerFields.USERNAMES)); + String punishmentTypeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); + String playerUuid = PlayerDataUtils.extractMinecraftUuid(row); + + activities.add(new ActivityItemResponse( + "punishment-" + punishment.getId(), + "new_punishment", + "red", + "Applied " + punishmentTypeName + " to " + username, + punishment.getIssued(), + "Applied " + punishmentTypeName + " punishment", + List.of(new ActivityItemResponse.ActivityAction("View Player", "/panel/players/" + playerUuid, true)) + )); + } + } catch (Exception exception) { + log.error("Error fetching punishment activities", exception); + } + } + + private record ActivePunishmentCounts(long bans, long mutes, long total) {} +} diff --git a/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java b/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java index 8c7b707..01409f0 100644 --- a/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java +++ b/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java @@ -1,623 +1,623 @@ -package gg.modl.backend.database; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.ServerFields; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; -import jakarta.annotation.PostConstruct; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.atomic.AtomicInteger; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.bson.Document; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.data.annotation.Id; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.index.Index; -import org.springframework.data.mongodb.core.index.IndexField; -import org.springframework.data.mongodb.core.index.IndexInfo; -import org.springframework.data.mongodb.core.index.IndexOperations; -import org.springframework.data.mongodb.core.index.PartialIndexFilter; -import org.springframework.data.mongodb.core.query.Collation; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MongoIndexBootstrapService { - private static final String TYPE_OPERATOR = "$type"; - private static final Map BSON_TYPE_CODES = Map.ofEntries( - Map.entry("double", 1), - Map.entry("string", 2), - Map.entry("object", 3), - Map.entry("array", 4), - Map.entry("binData", 5), - Map.entry("objectId", 7), - Map.entry("bool", 8), - Map.entry("date", 9), - Map.entry("null", 10), - Map.entry("regex", 11), - Map.entry("javascript", 13), - Map.entry("int", 16), - Map.entry("timestamp", 17), - Map.entry("long", 18), - Map.entry("decimal", 19), - Map.entry("minKey", -1), - Map.entry("maxKey", 127) - ); - - private static final int BOOTSTRAP_PARALLELISM = 4; - private static final String TENANT_BOOTSTRAP_LEASE = "tenant-schema-bootstrap"; - private static final Duration TENANT_BOOTSTRAP_LEASE_TTL = Duration.ofMinutes(30); - - private final TenantMongoAccess tenantMongoAccess; - private final TenantMigrationService tenantMigrationService; - private final SchedulerLeaseService schedulerLeaseService; - - @PostConstruct - public void initGlobalIndexes() { - try { - createGlobalIndexes(tenantMongoAccess.global()); - } catch (Exception e) { - log.error("Failed to create global database indexes", e); - } - try { - createTrainingDataIndexes(tenantMongoAccess.forDatabase(CollectionName.TRAINING_DATABASE)); - } catch (Exception e) { - log.error("Failed to create training data indexes", e); - } - } - - private void createTrainingDataIndexes(MongoTemplate template) { - ensureIndexes(template, CollectionName.TRAINING_SEGMENTS, List.of( - IndexSpec.standard( - "idx_training_segments_serverDatabaseName_replayId", - doc("serverDatabaseName", 1).append("replayId", 1), - false, - false - ) - )); - } - - @EventListener(ApplicationReadyEvent.class) - public void bootstrapExistingTenants() { - if (!schedulerLeaseService.tryAcquire(TENANT_BOOTSTRAP_LEASE, TENANT_BOOTSTRAP_LEASE_TTL)) { - log.info("Skipping tenant schema bootstrap; lease held by another instance"); - return; - } - List targets; - try { - targets = loadBootstrapTargets(); - } catch (Exception e) { - log.error("Failed to list servers for tenant bootstrap", e); - return; - } - - if (targets.isEmpty()) { - log.info("Bootstrapping schema for 0 existing tenants"); - return; - } - - dispatchTenantBootstrap(targets); - } - - private List loadBootstrapTargets() { - Query query = new Query(); - query.fields().include(ServerFields.ID).include(ServerFields.DATABASE_NAME); - return tenantMongoAccess.global() - .find(query, BootstrapTarget.class, CollectionName.MODL_SERVERS) - .stream() - .filter(target -> target.databaseName() != null && !target.databaseName().isBlank()) - .toList(); - } - - private void dispatchTenantBootstrap(List targets) { - log.info("Bootstrapping schema for {} existing tenants", targets.size()); - int parallelism = Math.min(BOOTSTRAP_PARALLELISM, targets.size()); - ExecutorService executor = Executors.newFixedThreadPool(parallelism, bootstrapThreadFactory()); - AtomicInteger succeeded = new AtomicInteger(); - AtomicInteger failed = new AtomicInteger(); - - CompletableFuture[] tasks = targets.stream() - .map(target -> CompletableFuture.runAsync(() -> bootstrapTenant(target, succeeded, failed), executor)) - .toArray(CompletableFuture[]::new); - - CompletableFuture.allOf(tasks).whenComplete((ignored, throwable) -> { - log.info("Tenant schema bootstrap complete succeeded={} failed={}", succeeded.get(), failed.get()); - executor.shutdown(); - }); - } - - private void bootstrapTenant(BootstrapTarget target, AtomicInteger succeeded, AtomicInteger failed) { - try { - log.debug("Bootstrapping schema for server id={} database={}", - target.id(), target.databaseName()); - MongoTemplate template = tenantMongoAccess.forDatabase(target.databaseName()); - tenantMigrationService.applyMigrationsForTenant(template); - createTenantIndexes(template); - succeeded.incrementAndGet(); - } catch (Exception e) { - failed.incrementAndGet(); - log.warn("Failed to bootstrap schema for server id={} database={}", - target.id(), target.databaseName(), e); - } - } - - private ThreadFactory bootstrapThreadFactory() { - AtomicInteger threadNumber = new AtomicInteger(); - return runnable -> { - Thread thread = new Thread(runnable, "tenant-bootstrap-" + threadNumber.incrementAndGet()); - thread.setDaemon(true); - return thread; - }; - } - - private void createGlobalIndexes(MongoTemplate template) { - ensureIndexes(template, CollectionName.MODL_SERVERS, List.of( - IndexSpec.standard("uidx_servers_serverName", doc("serverName", 1), true, false), - IndexSpec.standard("uidx_servers_customDomain", doc("customDomain", 1), true, false), - IndexSpec.standard("uidx_servers_adminEmail", doc("adminEmail", 1), true, false), - IndexSpec.standard("idx_servers_emailVerified", doc("emailVerified", 1), false, false), - IndexSpec.standard("uidx_servers_emailVerificationToken", doc("emailVerificationToken", 1), true, true), - IndexSpec.standard("idx_servers_provisioningStatus", doc("provisioningStatus", 1), false, false), - IndexSpec.standard( - "idx_servers_registration_cleanup", - doc("emailVerified", 1).append("provisioningStatus", 1).append("createdAt", 1).append("emailVerificationToken", 1), - false, - true - ), - IndexSpec.standard("uidx_servers_provisioningSignInToken", doc("provisioningSignInToken", 1), true, true), - IndexSpec.standard("uidx_servers_stripeCustomerId", doc("stripeCustomerId", 1), true, true), - IndexSpec.standard("uidx_servers_stripeSubscriptionId", doc("stripeSubscriptionId", 1), true, true), - IndexSpec.standard("uidx_servers_customDomainOverride", doc("customDomainOverride", 1), true, true), - IndexSpec.standard("uidx_servers_customDomainCloudflareId", doc("customDomainCloudflareId", 1), true, true), - IndexSpec.standard("uidx_servers_cliSetupToken", doc("cliSetupToken", 1), true, true), - IndexSpec.standard("uidx_servers_apiKey", doc("apiKey", 1), true, true), - IndexSpec.standard("idx_servers_userCount", doc("userCount", 1), false, false), - IndexSpec.standard("idx_servers_ticketCount", doc("ticketCount", 1), false, false), - IndexSpec.standard("idx_servers_lastStatsUpdatedAt", doc("lastStatsUpdatedAt", 1), false, false), - IndexSpec.standard("idx_servers_createdAt", doc("createdAt", 1), false, false), - IndexSpec.standard("idx_servers_lastActivityAt", doc("lastActivityAt", -1), false, true) - )); - - dropSupersededIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of("idx_metric_snapshots_date")); - ensureIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of( - IndexSpec.standard("uidx_metric_snapshots_date", doc("date", 1), true, false) - )); - - ensureIndexes(template, CollectionName.REPLAY_LITE_REPLAYS, List.of( - IndexSpec.standard("uidx_replay_lite_objectKey", doc("objectKey", 1), true, false), - IndexSpec.standard("idx_replay_lite_expiresAt", doc("expiresAt", 1), false, true), - IndexSpec.standard( - "idx_replay_lite_server_status_confirmedAt", - doc("pluginServerUuid", 1).append("status", 1).append("confirmedAt", 1), - false, - false - ), - IndexSpec.standard( - "idx_replay_lite_server_status_createdAt", - doc("pluginServerUuid", 1).append("status", 1).append("createdAt", 1), - false, - false - ), - IndexSpec.standard("idx_replay_lite_status_createdAt", doc("status", 1).append("createdAt", 1).append("_id", 1), false, false), - IndexSpec.standard("idx_replay_lite_status_expiresAt", doc("status", 1).append("expiresAt", 1).append("_id", 1), false, false) - )); - - ensureIndexes(template, CollectionName.REPLAY_LITE_DAILY_QUOTAS, List.of( - IndexSpec.standard( - "uidx_replay_lite_daily_quotas_server_day", - doc("pluginServerUuid", 1).append("day", 1), - true, - false - ), - IndexSpec.ttl("idx_replay_lite_daily_quotas_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, "admin_users", List.of( - IndexSpec.standard("uidx_admin_users_email", doc("email", 1), true, false) - )); - - ensureIndexes(template, CollectionName.SYSTEM_ALERTS, List.of( - IndexSpec.standard("idx_system_alerts_expiresAt", doc("expiresAt", 1), false, false), - IndexSpec.standard("idx_system_alerts_createdAt", doc("createdAt", -1), false, false), - IndexSpec.standard("idx_system_alerts_audience_expiresAt", doc("audience", 1).append("expiresAt", 1), false, false) - )); - - ensureIndexes(template, CollectionName.SERVER_INSTANCE_SNAPSHOTS, List.of( - IndexSpec.standard("uidx_server_instance_snapshots_date", doc("date", 1), true, false) - )); - - ensureIndexes(template, CollectionName.EVIDENCE_UPLOAD_TOKENS, List.of( - IndexSpec.ttl("idx_evidence_upload_tokens_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.BETA_AUDIT, List.of( - IndexSpec.standard("idx_beta_audit_serverId_timestamp", doc("serverId", 1).append("timestamp", -1), false, false) - )); - } - - public void createTenantIndexes(MongoTemplate template) { - ensureIndexes(template, CollectionName.SETTINGS, List.of( - IndexSpec.standard("uidx_settings_type", doc("type", 1), true, false) - )); - - ensureIndexes(template, CollectionName.PLAYERS, List.of( - IndexSpec.partialUnique("uidx_players_minecraftUuid", doc("minecraftUuid", 1), - new Document("minecraftUuid", new Document("$type", "string"))), - IndexSpec.standard("idx_players_punishments_issued_desc", doc("punishments.issued", -1), false, false), - IndexSpec.standard( - "idx_players_punishments_issuerName_issued_desc", - doc("punishments.issuerName", 1).append("punishments.issued", -1), - false, - false - ), - IndexSpec.standard( - "idx_players_punishments_issuerId_issued_desc", - doc("punishments.issuerId", 1).append("punishments.issued", -1), - false, - true - ), - IndexSpec.standard("idx_players_ipAddresses_ipAddress", doc("ipAddresses.ipAddress", 1), false, false), - IndexSpec.standard("idx_players_usernames_username", doc("usernames.username", 1), false, false), - IndexSpec.collated("idx_players_usernames_username_ci", doc("usernames.username", 1), - Collation.of("en").strength(2)), - IndexSpec.standard("idx_players_punishments_id", doc("punishments.id", 1), false, true), - IndexSpec.standard("idx_players_data_isOnline", doc("data.isOnline", 1), false, true), - IndexSpec.standard("idx_players_ipAddresses_firstLogin", doc("ipAddresses.firstLogin", -1), false, false) - )); - - ensureIndexes(template, CollectionName.STAFF, List.of( - IndexSpec.standard("uidx_staff_email", doc("email", 1), true, false), - IndexSpec.standard("uidx_staff_username", doc("username", 1), true, false), - IndexSpec.standard("sidx_staff_assignedMinecraftUuid", doc("assignedMinecraftUuid", 1), false, true) - )); - - ensureIndexes(template, CollectionName.STAFF_ROLES, List.of( - IndexSpec.standard("uidx_staff_roles_name", doc("name", 1), true, false), - IndexSpec.standard("idx_staff_roles_order", doc("order", 1), false, false) - )); - - ensureIndexes(template, CollectionName.INVITATIONS, List.of( - IndexSpec.standard("idx_invitations_email", doc("email", 1), false, false), - IndexSpec.standard("uidx_invitations_token", doc("token", 1), true, false), - IndexSpec.ttl("idx_invitations_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.TICKET_VERIFICATIONS, List.of( - IndexSpec.ttl("idx_ticket_verifications_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.TICKETS, List.of( - IndexSpec.standard("idx_tickets_status_created", doc("status", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_created", doc("created", -1), false, false), - IndexSpec.standard("idx_tickets_updatedAt", doc("updatedAt", -1), false, false), - IndexSpec.standard("idx_tickets_type_created", doc("type", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_creatorUuid_created", doc("creatorUuid", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_reportedPlayerUuid_created", doc("reportedPlayerUuid", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_locked_created", doc("locked", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_assignedTo_updatedAt", doc("assignedTo", 1).append("updatedAt", -1), false, false), - IndexSpec.standard("idx_tickets_creatorName_created", doc("creatorName", 1).append("created", -1), false, false), - IndexSpec.standard("idx_tickets_replies_name_created", doc("replies.name", 1).append("replies.created", -1), false, false), - IndexSpec.standard("idx_tickets_tags", doc("tags", 1), false, false), - IndexSpec.standard("idx_tickets_replayUrl", doc("replayUrl", 1), false, true), - IndexSpec.standard("idx_tickets_replayId", doc("replayId", 1), false, true) - )); - - ensureIndexes(template, CollectionName.REPLAYS, List.of( - IndexSpec.standard("idx_replays_targetUuid_createdAt", doc("targetUuid", 1).append("createdAt", -1), false, true), - IndexSpec.standard("idx_replays_status_createdAt", doc("status", 1).append("createdAt", 1).append("_id", 1), false, false), - IndexSpec.standard("idx_replays_storageKey", doc("storageKey", 1), false, false) - )); - - ensureIndexes(template, CollectionName.STORAGE_FILES, List.of( - IndexSpec.standard("uidx_storage_files_key", doc("key", 1), true, false), - IndexSpec.standard("idx_storage_files_key_createdAt", doc("key", 1).append("createdAt", -1), false, false) - )); - - ensureIndexes(template, CollectionName.KNOWLEDGEBASE_CATEGORIES, List.of( - IndexSpec.standard("uidx_knowledgebase_categories_slug", doc("slug", 1), true, false), - IndexSpec.standard("idx_knowledgebase_categories_name", doc("name", 1), false, false), - IndexSpec.standard("idx_knowledgebase_categories_ordinal", doc("ordinal", 1), false, false), - IndexSpec.standard("idx_knowledgebase_categories_isVisible_ordinal", doc("isVisible", 1).append("ordinal", 1), false, false) - )); - - ensureIndexes(template, CollectionName.KNOWLEDGEBASE_ARTICLES, List.of( - IndexSpec.standard("uidx_knowledgebase_articles_slug", doc("slug", 1), true, false), - IndexSpec.standard("idx_knowledgebase_articles_categoryId_ordinal", doc("categoryId", 1).append("ordinal", 1), false, false), - IndexSpec.standard("idx_knowledgebase_articles_isVisible_categoryId_ordinal", doc("isVisible", 1).append("categoryId", 1).append("ordinal", 1), - false, false) - )); - - ensureIndexes(template, CollectionName.WEBAUTHN_CREDENTIALS, List.of( - IndexSpec.standard("idx_webauthn_credentials_email", doc("email", 1), false, false), - IndexSpec.standard("uidx_webauthn_credentials_credentialId", doc("credentialId", 1), true, false), - IndexSpec.standard("idx_webauthn_credentials_userHandle", doc("userHandle", 1), false, false) - )); - - ensureIndexes(template, CollectionName.WEBAUTHN_CHALLENGES, List.of( - IndexSpec.ttl("idx_webauthn_challenges_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.HOMEPAGE_CARDS, List.of( - IndexSpec.standard("idx_homepage_cards_ordinal", doc("ordinal", 1), false, false), - IndexSpec.standard("idx_homepage_cards_isEnabled_ordinal", doc("isEnabled", 1).append("ordinal", 1), false, false), - IndexSpec.standard("idx_homepage_cards_categoryId", doc("categoryId", 1), false, true) - )); - - ensureIndexes(template, CollectionName.SESSIONS, List.of( - IndexSpec.standard("idx_sessions_email", doc("email", 1), false, false), - IndexSpec.ttl("idx_sessions_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.AUTH_CODES, List.of( - IndexSpec.ttl("idx_auth_codes_expiresAt_ttl", doc("expiresAt", 1), 0) - )); - - ensureIndexes(template, CollectionName.SYSTEM_LOGS, List.of( - IndexSpec.standard("idx_system_logs_timestamp", doc("timestamp", -1), false, false), - IndexSpec.standard("idx_system_logs_level_timestamp", doc("level", 1).append("timestamp", -1), false, false), - IndexSpec.standard("idx_system_logs_source_timestamp", doc("source", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.SECURITY_EVENTS, List.of( - IndexSpec.standard("idx_security_events_timestamp", doc("timestamp", -1), false, false), - IndexSpec.standard("idx_security_events_severity_timestamp", doc("severity", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.CHAT_LOGS, List.of( - IndexSpec.standard("idx_chat_logs_uuid_timestamp", doc("uuid", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.COMMAND_LOGS, List.of( - IndexSpec.standard("idx_command_logs_uuid_timestamp", doc("uuid", 1).append("timestamp", -1), false, false) - )); - - ensureIndexes(template, CollectionName.LOGS, List.of( - IndexSpec.standard("idx_logs_created_desc", doc("created", -1), false, false) - )); - - ensureIndexes(template, CollectionName.MIGRATIONS, List.of( - IndexSpec.standard("idx_migrations_status_startedAt", doc("status", 1).append("startedAt", -1), false, false) - )); - } - - private void dropSupersededIndexes(MongoTemplate template, String collectionName, List legacyIndexNames) { - IndexOperations indexOps = template.indexOps(collectionName); - List existingNames = indexOps.getIndexInfo().stream().map(IndexInfo::getName).toList(); - for (String legacyName : legacyIndexNames) { - if (!existingNames.contains(legacyName)) { - continue; - } - try { - indexOps.dropIndex(legacyName); - log.info("Dropped superseded index name={} on collection={}", legacyName, collectionName); - } catch (Exception e) { - log.warn("Failed to drop superseded index name={} on collection={}", legacyName, collectionName, e); - } - } - } - - private void ensureIndexes(MongoTemplate template, String collectionName, List specs) { - IndexOperations indexOps = template.indexOps(collectionName); - List existingIndexes = indexOps.getIndexInfo(); - for (IndexSpec spec : specs) { - if (hasEquivalentIndex(existingIndexes, spec)) { - continue; - } - try { - boolean nameCollision = existingIndexes.stream() - .anyMatch(i -> spec.name().equals(i.getName())); - if (nameCollision) { - log.warn("Index spec drift on collection={} index={}: existing definition differs" - + " from source spec; dropping and recreating", collectionName, spec.name()); - try { - indexOps.dropIndex(spec.name()); - } catch (Exception e) { - log.error("Failed to drop conflicting index name={} on collection={};" - + " new spec NOT applied", spec.name(), collectionName, e); - continue; - } - } - createIndex(indexOps, spec); - } catch (Exception e) { - log.error("Failed to create index name={} on collection={}; continuing", - spec.name(), collectionName, e); - } - } - } - - private void createIndex(IndexOperations indexOps, IndexSpec spec) { - Index index = new Index().named(spec.name()); - - for (Map.Entry entry : spec.keys().entrySet()) { - Sort.Direction direction = directionFrom(entry.getValue()); - index.on(entry.getKey(), direction); - } - - if (spec.unique()) { - index.unique(); - } - if (spec.sparse()) { - index.sparse(); - } - if (spec.ttlSeconds() != null) { - index.expire(Duration.ofSeconds(spec.ttlSeconds())); - } - if (spec.partialFilter() != null) { - index.partial(PartialIndexFilter.of(spec.partialFilter())); - } - if (spec.collation() != null) { - index.collation(spec.collation()); - } - - indexOps.createIndex(index); - } - - private Sort.Direction directionFrom(Object value) { - if (value instanceof Number number) { - return number.intValue() < 0 ? Sort.Direction.DESC : Sort.Direction.ASC; - } - throw new ValidationException("Unsupported index direction value: " + value); - } - - private boolean hasEquivalentIndex(List existingIndexes, IndexSpec spec) { - List expectedFields = fieldsFor(spec.keys()); - for (IndexInfo existingIndex : existingIndexes) { - if (!existingIndex.getIndexFields().equals(expectedFields)) { - continue; - } - if (existingIndex.isUnique() != spec.unique()) { - continue; - } - if (existingIndex.isSparse() != spec.sparse()) { - continue; - } - - long existingTtlSeconds = existingIndex.getExpireAfter().map(Duration::getSeconds).orElse(-1L); - long expectedTtlSeconds = spec.ttlSeconds() == null ? -1L : spec.ttlSeconds(); - if (existingTtlSeconds != expectedTtlSeconds) { - continue; - } - - if (!hasEquivalentCollation(existingIndex, spec)) { - continue; - } - - String existingPartialJson = existingIndex.getPartialFilterExpression(); - Document specPartial = spec.partialFilter(); - if (existingPartialJson == null && specPartial == null) { - return true; - } - if (existingPartialJson == null || specPartial == null) { - continue; - } - if (!canonicalPartialFilter(Document.parse(existingPartialJson)) - .equals(canonicalPartialFilter(specPartial))) { - continue; - } - - return true; - } - return false; - } - - private boolean hasEquivalentCollation(IndexInfo existingIndex, IndexSpec spec) { - Document existingCollation = existingIndex.getCollation().orElse(null); - if (spec.collation() == null) { - return existingCollation == null; - } - if (existingCollation == null) { - return false; - } - Document specCollation = spec.collation().toDocument(); - for (Map.Entry entry : specCollation.entrySet()) { - if (!collationValueEquals(entry.getValue(), existingCollation.get(entry.getKey()))) { - return false; - } - } - return true; - } - - private boolean collationValueEquals(Object specValue, Object existingValue) { - if (specValue instanceof Number specNumber && existingValue instanceof Number existingNumber) { - return specNumber.doubleValue() == existingNumber.doubleValue(); - } - return Objects.equals(specValue, existingValue); - } - - private Document canonicalPartialFilter(Document filter) { - Document canonical = new Document(); - for (Map.Entry entry : filter.entrySet()) { - canonical.put(entry.getKey(), canonicalFilterValue(entry.getKey(), entry.getValue())); - } - return canonical; - } - - private Object canonicalFilterValue(String key, Object value) { - if (TYPE_OPERATOR.equals(key)) { - return canonicalBsonType(value); - } - if (value instanceof Document nested) { - return canonicalPartialFilter(nested); - } - if (value instanceof List elements) { - List canonical = new ArrayList<>(elements.size()); - for (Object element : elements) { - canonical.add(element instanceof Document nested ? canonicalPartialFilter(nested) : element); - } - return canonical; - } - return value; - } - - private Object canonicalBsonType(Object value) { - if (value instanceof Number number) { - return number.intValue(); - } - if (value instanceof String alias) { - Integer code = BSON_TYPE_CODES.get(alias); - return code != null ? code : alias; - } - if (value instanceof List aliases) { - List canonical = new ArrayList<>(aliases.size()); - for (Object alias : aliases) { - canonical.add(canonicalBsonType(alias)); - } - return canonical; - } - return value; - } - - private List fieldsFor(Document keys) { - List fields = new ArrayList<>(keys.size()); - for (Map.Entry entry : keys.entrySet()) { - fields.add(IndexField.create(entry.getKey(), directionFrom(entry.getValue()))); - } - return fields; - } - - private Document doc(String field, int direction) { - return new Document(field, direction); - } - - record BootstrapTarget(@Id String id, String databaseName) { - } - - private record IndexSpec( - String name, - Document keys, - boolean unique, - boolean sparse, - Long ttlSeconds, - Document partialFilter, - Collation collation - ) { - static IndexSpec standard(String name, Document keys, boolean unique, boolean sparse) { - return new IndexSpec(name, keys, unique, sparse, null, null, null); - } - - static IndexSpec ttl(String name, Document keys, long ttlSeconds) { - return new IndexSpec(name, keys, false, false, ttlSeconds, null, null); - } - - static IndexSpec partialUnique(String name, Document keys, Document partialFilter) { - return new IndexSpec(name, keys, true, false, null, partialFilter, null); - } - - static IndexSpec collated(String name, Document keys, Collation collation) { - return new IndexSpec(name, keys, false, false, null, null, collation); - } - } -} +package gg.modl.backend.database; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; +import jakarta.annotation.PostConstruct; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.bson.Document; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.index.IndexField; +import org.springframework.data.mongodb.core.index.IndexInfo; +import org.springframework.data.mongodb.core.index.IndexOperations; +import org.springframework.data.mongodb.core.index.PartialIndexFilter; +import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MongoIndexBootstrapService { + private static final String TYPE_OPERATOR = "$type"; + private static final Map BSON_TYPE_CODES = Map.ofEntries( + Map.entry("double", 1), + Map.entry("string", 2), + Map.entry("object", 3), + Map.entry("array", 4), + Map.entry("binData", 5), + Map.entry("objectId", 7), + Map.entry("bool", 8), + Map.entry("date", 9), + Map.entry("null", 10), + Map.entry("regex", 11), + Map.entry("javascript", 13), + Map.entry("int", 16), + Map.entry("timestamp", 17), + Map.entry("long", 18), + Map.entry("decimal", 19), + Map.entry("minKey", -1), + Map.entry("maxKey", 127) + ); + + private static final int BOOTSTRAP_PARALLELISM = 4; + private static final String TENANT_BOOTSTRAP_LEASE = "tenant-schema-bootstrap"; + private static final Duration TENANT_BOOTSTRAP_LEASE_TTL = Duration.ofMinutes(30); + + private final TenantMongoAccess tenantMongoAccess; + private final TenantMigrationService tenantMigrationService; + private final SchedulerLeaseService schedulerLeaseService; + + @PostConstruct + public void initGlobalIndexes() { + try { + createGlobalIndexes(tenantMongoAccess.global()); + } catch (Exception e) { + log.error("Failed to create global database indexes", e); + } + try { + createTrainingDataIndexes(tenantMongoAccess.forDatabase(CollectionName.TRAINING_DATABASE)); + } catch (Exception e) { + log.error("Failed to create training data indexes", e); + } + } + + private void createTrainingDataIndexes(MongoTemplate template) { + ensureIndexes(template, CollectionName.TRAINING_SEGMENTS, List.of( + IndexSpec.standard( + "idx_training_segments_serverDatabaseName_replayId", + doc("serverDatabaseName", 1).append("replayId", 1), + false, + false + ) + )); + } + + @EventListener(ApplicationReadyEvent.class) + public void bootstrapExistingTenants() { + if (!schedulerLeaseService.tryAcquire(TENANT_BOOTSTRAP_LEASE, TENANT_BOOTSTRAP_LEASE_TTL)) { + log.info("Skipping tenant schema bootstrap; lease held by another instance"); + return; + } + List targets; + try { + targets = loadBootstrapTargets(); + } catch (Exception e) { + log.error("Failed to list servers for tenant bootstrap", e); + return; + } + + if (targets.isEmpty()) { + log.info("Bootstrapping schema for 0 existing tenants"); + return; + } + + dispatchTenantBootstrap(targets); + } + + private List loadBootstrapTargets() { + Query query = new Query(); + query.fields().include(ServerFields.ID).include(ServerFields.DATABASE_NAME); + return tenantMongoAccess.global() + .find(query, BootstrapTarget.class, CollectionName.MODL_SERVERS) + .stream() + .filter(target -> target.databaseName() != null && !target.databaseName().isBlank()) + .toList(); + } + + private void dispatchTenantBootstrap(List targets) { + log.info("Bootstrapping schema for {} existing tenants", targets.size()); + int parallelism = Math.min(BOOTSTRAP_PARALLELISM, targets.size()); + ExecutorService executor = Executors.newFixedThreadPool(parallelism, bootstrapThreadFactory()); + AtomicInteger succeeded = new AtomicInteger(); + AtomicInteger failed = new AtomicInteger(); + + CompletableFuture[] tasks = targets.stream() + .map(target -> CompletableFuture.runAsync(() -> bootstrapTenant(target, succeeded, failed), executor)) + .toArray(CompletableFuture[]::new); + + CompletableFuture.allOf(tasks).whenComplete((ignored, throwable) -> { + log.info("Tenant schema bootstrap complete succeeded={} failed={}", succeeded.get(), failed.get()); + executor.shutdown(); + }); + } + + private void bootstrapTenant(BootstrapTarget target, AtomicInteger succeeded, AtomicInteger failed) { + try { + log.debug("Bootstrapping schema for server id={} database={}", + target.id(), target.databaseName()); + MongoTemplate template = tenantMongoAccess.forDatabase(target.databaseName()); + tenantMigrationService.applyMigrationsForTenant(template); + createTenantIndexes(template); + succeeded.incrementAndGet(); + } catch (Exception e) { + failed.incrementAndGet(); + log.warn("Failed to bootstrap schema for server id={} database={}", + target.id(), target.databaseName(), e); + } + } + + private ThreadFactory bootstrapThreadFactory() { + AtomicInteger threadNumber = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, "tenant-bootstrap-" + threadNumber.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } + + private void createGlobalIndexes(MongoTemplate template) { + ensureIndexes(template, CollectionName.MODL_SERVERS, List.of( + IndexSpec.standard("uidx_servers_serverName", doc("serverName", 1), true, false), + IndexSpec.standard("uidx_servers_customDomain", doc("customDomain", 1), true, false), + IndexSpec.standard("uidx_servers_adminEmail", doc("adminEmail", 1), true, false), + IndexSpec.standard("idx_servers_emailVerified", doc("emailVerified", 1), false, false), + IndexSpec.standard("uidx_servers_emailVerificationToken", doc("emailVerificationToken", 1), true, true), + IndexSpec.standard("idx_servers_provisioningStatus", doc("provisioningStatus", 1), false, false), + IndexSpec.standard( + "idx_servers_registration_cleanup", + doc("emailVerified", 1).append("provisioningStatus", 1).append("createdAt", 1).append("emailVerificationToken", 1), + false, + true + ), + IndexSpec.standard("uidx_servers_provisioningSignInToken", doc("provisioningSignInToken", 1), true, true), + IndexSpec.standard("uidx_servers_stripeCustomerId", doc("stripeCustomerId", 1), true, true), + IndexSpec.standard("uidx_servers_stripeSubscriptionId", doc("stripeSubscriptionId", 1), true, true), + IndexSpec.standard("uidx_servers_customDomainOverride", doc("customDomainOverride", 1), true, true), + IndexSpec.standard("uidx_servers_customDomainCloudflareId", doc("customDomainCloudflareId", 1), true, true), + IndexSpec.standard("uidx_servers_cliSetupToken", doc("cliSetupToken", 1), true, true), + IndexSpec.standard("uidx_servers_apiKey", doc("apiKey", 1), true, true), + IndexSpec.standard("idx_servers_userCount", doc("userCount", 1), false, false), + IndexSpec.standard("idx_servers_ticketCount", doc("ticketCount", 1), false, false), + IndexSpec.standard("idx_servers_lastStatsUpdatedAt", doc("lastStatsUpdatedAt", 1), false, false), + IndexSpec.standard("idx_servers_createdAt", doc("createdAt", 1), false, false), + IndexSpec.standard("idx_servers_lastActivityAt", doc("lastActivityAt", -1), false, true) + )); + + dropSupersededIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of("idx_metric_snapshots_date")); + ensureIndexes(template, CollectionName.METRIC_SNAPSHOTS, List.of( + IndexSpec.standard("uidx_metric_snapshots_date", doc("date", 1), true, false) + )); + + ensureIndexes(template, CollectionName.REPLAY_LITE_REPLAYS, List.of( + IndexSpec.standard("uidx_replay_lite_objectKey", doc("objectKey", 1), true, false), + IndexSpec.standard("idx_replay_lite_expiresAt", doc("expiresAt", 1), false, true), + IndexSpec.standard( + "idx_replay_lite_server_status_confirmedAt", + doc("pluginServerUuid", 1).append("status", 1).append("confirmedAt", 1), + false, + false + ), + IndexSpec.standard( + "idx_replay_lite_server_status_createdAt", + doc("pluginServerUuid", 1).append("status", 1).append("createdAt", 1), + false, + false + ), + IndexSpec.standard("idx_replay_lite_status_createdAt", doc("status", 1).append("createdAt", 1).append("_id", 1), false, false), + IndexSpec.standard("idx_replay_lite_status_expiresAt", doc("status", 1).append("expiresAt", 1).append("_id", 1), false, false) + )); + + ensureIndexes(template, CollectionName.REPLAY_LITE_DAILY_QUOTAS, List.of( + IndexSpec.standard( + "uidx_replay_lite_daily_quotas_server_day", + doc("pluginServerUuid", 1).append("day", 1), + true, + false + ), + IndexSpec.ttl("idx_replay_lite_daily_quotas_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, "admin_users", List.of( + IndexSpec.standard("uidx_admin_users_email", doc("email", 1), true, false) + )); + + ensureIndexes(template, CollectionName.SYSTEM_ALERTS, List.of( + IndexSpec.standard("idx_system_alerts_expiresAt", doc("expiresAt", 1), false, false), + IndexSpec.standard("idx_system_alerts_createdAt", doc("createdAt", -1), false, false), + IndexSpec.standard("idx_system_alerts_audience_expiresAt", doc("audience", 1).append("expiresAt", 1), false, false) + )); + + ensureIndexes(template, CollectionName.SERVER_INSTANCE_SNAPSHOTS, List.of( + IndexSpec.standard("uidx_server_instance_snapshots_date", doc("date", 1), true, false) + )); + + ensureIndexes(template, CollectionName.EVIDENCE_UPLOAD_TOKENS, List.of( + IndexSpec.ttl("idx_evidence_upload_tokens_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, CollectionName.BETA_AUDIT, List.of( + IndexSpec.standard("idx_beta_audit_serverId_timestamp", doc("serverId", 1).append("timestamp", -1), false, false) + )); + } + + public void createTenantIndexes(MongoTemplate template) { + ensureIndexes(template, CollectionName.SETTINGS, List.of( + IndexSpec.standard("uidx_settings_type", doc("type", 1), true, false) + )); + + ensureIndexes(template, CollectionName.PLAYERS, List.of( + IndexSpec.partialUnique("uidx_players_minecraftUuid", doc("minecraftUuid", 1), + new Document("minecraftUuid", new Document("$type", "string"))), + IndexSpec.standard("idx_players_punishments_issued_desc", doc("punishments.issued", -1), false, false), + IndexSpec.standard( + "idx_players_punishments_issuerName_issued_desc", + doc("punishments.issuerName", 1).append("punishments.issued", -1), + false, + false + ), + IndexSpec.standard( + "idx_players_punishments_issuerId_issued_desc", + doc("punishments.issuerId", 1).append("punishments.issued", -1), + false, + true + ), + IndexSpec.standard("idx_players_ipAddresses_ipAddress", doc("ipAddresses.ipAddress", 1), false, false), + IndexSpec.standard("idx_players_usernames_username", doc("usernames.username", 1), false, false), + IndexSpec.collated("idx_players_usernames_username_ci", doc("usernames.username", 1), + Collation.of("en").strength(2)), + IndexSpec.standard("idx_players_punishments_id", doc("punishments.id", 1), false, true), + IndexSpec.standard("idx_players_data_isOnline", doc("data.isOnline", 1), false, true), + IndexSpec.standard("idx_players_ipAddresses_firstLogin", doc("ipAddresses.firstLogin", -1), false, false) + )); + + ensureIndexes(template, CollectionName.STAFF, List.of( + IndexSpec.standard("uidx_staff_email", doc("email", 1), true, false), + IndexSpec.standard("uidx_staff_username", doc("username", 1), true, false), + IndexSpec.standard("sidx_staff_assignedMinecraftUuid", doc("assignedMinecraftUuid", 1), false, true) + )); + + ensureIndexes(template, CollectionName.STAFF_ROLES, List.of( + IndexSpec.standard("uidx_staff_roles_name", doc("name", 1), true, false), + IndexSpec.standard("idx_staff_roles_order", doc("order", 1), false, false) + )); + + ensureIndexes(template, CollectionName.INVITATIONS, List.of( + IndexSpec.standard("idx_invitations_email", doc("email", 1), false, false), + IndexSpec.standard("uidx_invitations_token", doc("token", 1), true, false), + IndexSpec.ttl("idx_invitations_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, CollectionName.TICKET_VERIFICATIONS, List.of( + IndexSpec.ttl("idx_ticket_verifications_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, CollectionName.TICKETS, List.of( + IndexSpec.standard("idx_tickets_status_created", doc("status", 1).append("created", -1), false, false), + IndexSpec.standard("idx_tickets_created", doc("created", -1), false, false), + IndexSpec.standard("idx_tickets_updatedAt", doc("updatedAt", -1), false, false), + IndexSpec.standard("idx_tickets_type_created", doc("type", 1).append("created", -1), false, false), + IndexSpec.standard("idx_tickets_creatorUuid_created", doc("creatorUuid", 1).append("created", -1), false, false), + IndexSpec.standard("idx_tickets_reportedPlayerUuid_created", doc("reportedPlayerUuid", 1).append("created", -1), false, false), + IndexSpec.standard("idx_tickets_locked_created", doc("locked", 1).append("created", -1), false, false), + IndexSpec.standard("idx_tickets_assignedTo_updatedAt", doc("assignedTo", 1).append("updatedAt", -1), false, false), + IndexSpec.standard("idx_tickets_creatorName_created", doc("creatorName", 1).append("created", -1), false, false), + IndexSpec.standard("idx_tickets_replies_name_created", doc("replies.name", 1).append("replies.created", -1), false, false), + IndexSpec.standard("idx_tickets_tags", doc("tags", 1), false, false), + IndexSpec.standard("idx_tickets_replayUrl", doc("replayUrl", 1), false, true), + IndexSpec.standard("idx_tickets_replayId", doc("replayId", 1), false, true) + )); + + ensureIndexes(template, CollectionName.REPLAYS, List.of( + IndexSpec.standard("idx_replays_targetUuid_createdAt", doc("targetUuid", 1).append("createdAt", -1), false, true), + IndexSpec.standard("idx_replays_status_createdAt", doc("status", 1).append("createdAt", 1).append("_id", 1), false, false), + IndexSpec.standard("idx_replays_storageKey", doc("storageKey", 1), false, false) + )); + + ensureIndexes(template, CollectionName.STORAGE_FILES, List.of( + IndexSpec.standard("uidx_storage_files_key", doc("key", 1), true, false), + IndexSpec.standard("idx_storage_files_key_createdAt", doc("key", 1).append("createdAt", -1), false, false) + )); + + ensureIndexes(template, CollectionName.KNOWLEDGEBASE_CATEGORIES, List.of( + IndexSpec.standard("uidx_knowledgebase_categories_slug", doc("slug", 1), true, false), + IndexSpec.standard("idx_knowledgebase_categories_name", doc("name", 1), false, false), + IndexSpec.standard("idx_knowledgebase_categories_ordinal", doc("ordinal", 1), false, false), + IndexSpec.standard("idx_knowledgebase_categories_isVisible_ordinal", doc("isVisible", 1).append("ordinal", 1), false, false) + )); + + ensureIndexes(template, CollectionName.KNOWLEDGEBASE_ARTICLES, List.of( + IndexSpec.standard("uidx_knowledgebase_articles_slug", doc("slug", 1), true, false), + IndexSpec.standard("idx_knowledgebase_articles_categoryId_ordinal", doc("categoryId", 1).append("ordinal", 1), false, false), + IndexSpec.standard("idx_knowledgebase_articles_isVisible_categoryId_ordinal", doc("isVisible", 1).append("categoryId", 1).append("ordinal", 1), + false, false) + )); + + ensureIndexes(template, CollectionName.WEBAUTHN_CREDENTIALS, List.of( + IndexSpec.standard("idx_webauthn_credentials_email", doc("email", 1), false, false), + IndexSpec.standard("uidx_webauthn_credentials_credentialId", doc("credentialId", 1), true, false), + IndexSpec.standard("idx_webauthn_credentials_userHandle", doc("userHandle", 1), false, false) + )); + + ensureIndexes(template, CollectionName.WEBAUTHN_CHALLENGES, List.of( + IndexSpec.ttl("idx_webauthn_challenges_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, CollectionName.HOMEPAGE_CARDS, List.of( + IndexSpec.standard("idx_homepage_cards_ordinal", doc("ordinal", 1), false, false), + IndexSpec.standard("idx_homepage_cards_isEnabled_ordinal", doc("isEnabled", 1).append("ordinal", 1), false, false), + IndexSpec.standard("idx_homepage_cards_categoryId", doc("categoryId", 1), false, true) + )); + + ensureIndexes(template, CollectionName.SESSIONS, List.of( + IndexSpec.standard("idx_sessions_email", doc("email", 1), false, false), + IndexSpec.ttl("idx_sessions_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, CollectionName.AUTH_CODES, List.of( + IndexSpec.ttl("idx_auth_codes_expiresAt_ttl", doc("expiresAt", 1), 0) + )); + + ensureIndexes(template, CollectionName.SYSTEM_LOGS, List.of( + IndexSpec.standard("idx_system_logs_timestamp", doc("timestamp", -1), false, false), + IndexSpec.standard("idx_system_logs_level_timestamp", doc("level", 1).append("timestamp", -1), false, false), + IndexSpec.standard("idx_system_logs_source_timestamp", doc("source", 1).append("timestamp", -1), false, false) + )); + + ensureIndexes(template, CollectionName.SECURITY_EVENTS, List.of( + IndexSpec.standard("idx_security_events_timestamp", doc("timestamp", -1), false, false), + IndexSpec.standard("idx_security_events_severity_timestamp", doc("severity", 1).append("timestamp", -1), false, false) + )); + + ensureIndexes(template, CollectionName.CHAT_LOGS, List.of( + IndexSpec.standard("idx_chat_logs_uuid_timestamp", doc("uuid", 1).append("timestamp", -1), false, false) + )); + + ensureIndexes(template, CollectionName.COMMAND_LOGS, List.of( + IndexSpec.standard("idx_command_logs_uuid_timestamp", doc("uuid", 1).append("timestamp", -1), false, false) + )); + + ensureIndexes(template, CollectionName.LOGS, List.of( + IndexSpec.standard("idx_logs_created_desc", doc("created", -1), false, false) + )); + + ensureIndexes(template, CollectionName.MIGRATIONS, List.of( + IndexSpec.standard("idx_migrations_status_startedAt", doc("status", 1).append("startedAt", -1), false, false) + )); + } + + private void dropSupersededIndexes(MongoTemplate template, String collectionName, List legacyIndexNames) { + IndexOperations indexOps = template.indexOps(collectionName); + List existingNames = indexOps.getIndexInfo().stream().map(IndexInfo::getName).toList(); + for (String legacyName : legacyIndexNames) { + if (!existingNames.contains(legacyName)) { + continue; + } + try { + indexOps.dropIndex(legacyName); + log.info("Dropped superseded index name={} on collection={}", legacyName, collectionName); + } catch (Exception e) { + log.warn("Failed to drop superseded index name={} on collection={}", legacyName, collectionName, e); + } + } + } + + private void ensureIndexes(MongoTemplate template, String collectionName, List specs) { + IndexOperations indexOps = template.indexOps(collectionName); + List existingIndexes = indexOps.getIndexInfo(); + for (IndexSpec spec : specs) { + if (hasEquivalentIndex(existingIndexes, spec)) { + continue; + } + try { + boolean nameCollision = existingIndexes.stream() + .anyMatch(i -> spec.name().equals(i.getName())); + if (nameCollision) { + log.warn("Index spec drift on collection={} index={}: existing definition differs" + + " from source spec; dropping and recreating", collectionName, spec.name()); + try { + indexOps.dropIndex(spec.name()); + } catch (Exception e) { + log.error("Failed to drop conflicting index name={} on collection={};" + + " new spec NOT applied", spec.name(), collectionName, e); + continue; + } + } + createIndex(indexOps, spec); + } catch (Exception e) { + log.error("Failed to create index name={} on collection={}; continuing", + spec.name(), collectionName, e); + } + } + } + + private void createIndex(IndexOperations indexOps, IndexSpec spec) { + Index index = new Index().named(spec.name()); + + for (Map.Entry entry : spec.keys().entrySet()) { + Sort.Direction direction = directionFrom(entry.getValue()); + index.on(entry.getKey(), direction); + } + + if (spec.unique()) { + index.unique(); + } + if (spec.sparse()) { + index.sparse(); + } + if (spec.ttlSeconds() != null) { + index.expire(Duration.ofSeconds(spec.ttlSeconds())); + } + if (spec.partialFilter() != null) { + index.partial(PartialIndexFilter.of(spec.partialFilter())); + } + if (spec.collation() != null) { + index.collation(spec.collation()); + } + + indexOps.createIndex(index); + } + + private Sort.Direction directionFrom(Object value) { + if (value instanceof Number number) { + return number.intValue() < 0 ? Sort.Direction.DESC : Sort.Direction.ASC; + } + throw new ValidationException("Unsupported index direction value: " + value); + } + + private boolean hasEquivalentIndex(List existingIndexes, IndexSpec spec) { + List expectedFields = fieldsFor(spec.keys()); + for (IndexInfo existingIndex : existingIndexes) { + if (!existingIndex.getIndexFields().equals(expectedFields)) { + continue; + } + if (existingIndex.isUnique() != spec.unique()) { + continue; + } + if (existingIndex.isSparse() != spec.sparse()) { + continue; + } + + long existingTtlSeconds = existingIndex.getExpireAfter().map(Duration::getSeconds).orElse(-1L); + long expectedTtlSeconds = spec.ttlSeconds() == null ? -1L : spec.ttlSeconds(); + if (existingTtlSeconds != expectedTtlSeconds) { + continue; + } + + if (!hasEquivalentCollation(existingIndex, spec)) { + continue; + } + + String existingPartialJson = existingIndex.getPartialFilterExpression(); + Document specPartial = spec.partialFilter(); + if (existingPartialJson == null && specPartial == null) { + return true; + } + if (existingPartialJson == null || specPartial == null) { + continue; + } + if (!canonicalPartialFilter(Document.parse(existingPartialJson)) + .equals(canonicalPartialFilter(specPartial))) { + continue; + } + + return true; + } + return false; + } + + private boolean hasEquivalentCollation(IndexInfo existingIndex, IndexSpec spec) { + Document existingCollation = existingIndex.getCollation().orElse(null); + if (spec.collation() == null) { + return existingCollation == null; + } + if (existingCollation == null) { + return false; + } + Document specCollation = spec.collation().toDocument(); + for (Map.Entry entry : specCollation.entrySet()) { + if (!collationValueEquals(entry.getValue(), existingCollation.get(entry.getKey()))) { + return false; + } + } + return true; + } + + private boolean collationValueEquals(Object specValue, Object existingValue) { + if (specValue instanceof Number specNumber && existingValue instanceof Number existingNumber) { + return specNumber.doubleValue() == existingNumber.doubleValue(); + } + return Objects.equals(specValue, existingValue); + } + + private Document canonicalPartialFilter(Document filter) { + Document canonical = new Document(); + for (Map.Entry entry : filter.entrySet()) { + canonical.put(entry.getKey(), canonicalFilterValue(entry.getKey(), entry.getValue())); + } + return canonical; + } + + private Object canonicalFilterValue(String key, Object value) { + if (TYPE_OPERATOR.equals(key)) { + return canonicalBsonType(value); + } + if (value instanceof Document nested) { + return canonicalPartialFilter(nested); + } + if (value instanceof List elements) { + List canonical = new ArrayList<>(elements.size()); + for (Object element : elements) { + canonical.add(element instanceof Document nested ? canonicalPartialFilter(nested) : element); + } + return canonical; + } + return value; + } + + private Object canonicalBsonType(Object value) { + if (value instanceof Number number) { + return number.intValue(); + } + if (value instanceof String alias) { + Integer code = BSON_TYPE_CODES.get(alias); + return code != null ? code : alias; + } + if (value instanceof List aliases) { + List canonical = new ArrayList<>(aliases.size()); + for (Object alias : aliases) { + canonical.add(canonicalBsonType(alias)); + } + return canonical; + } + return value; + } + + private List fieldsFor(Document keys) { + List fields = new ArrayList<>(keys.size()); + for (Map.Entry entry : keys.entrySet()) { + fields.add(IndexField.create(entry.getKey(), directionFrom(entry.getValue()))); + } + return fields; + } + + private Document doc(String field, int direction) { + return new Document(field, direction); + } + + record BootstrapTarget(@Id String id, String databaseName) { + } + + private record IndexSpec( + String name, + Document keys, + boolean unique, + boolean sparse, + Long ttlSeconds, + Document partialFilter, + Collation collation + ) { + static IndexSpec standard(String name, Document keys, boolean unique, boolean sparse) { + return new IndexSpec(name, keys, unique, sparse, null, null, null); + } + + static IndexSpec ttl(String name, Document keys, long ttlSeconds) { + return new IndexSpec(name, keys, false, false, ttlSeconds, null, null); + } + + static IndexSpec partialUnique(String name, Document keys, Document partialFilter) { + return new IndexSpec(name, keys, true, false, null, partialFilter, null); + } + + static IndexSpec collated(String name, Document keys, Collation collation) { + return new IndexSpec(name, keys, false, false, null, null, collation); + } + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java index 7df1e0d..3c6b62b 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AnalyticsMongoRepository.java @@ -42,6 +42,10 @@ public class AnalyticsMongoRepository { private final TenantMongoAccess tenantMongoAccess; + private static Document matchDateTyped(String field) { + return new Document("$match", new Document(field, new Document("$type", "date"))); + } + @NotNull public OverviewStats loadOverviewStats(@NotNull Server server, @NotNull Date thirtyDaysAgo, @NotNull Date sixtyDaysAgo) { final MongoTemplate template = tenantMongoAccess.forServer(server); @@ -117,6 +121,8 @@ public List aggregateDailyTicketCounts(Server server, Date startD criteria = criteria.and(TicketFields.CREATED).gte(startDate); } + final AggregationOperation createdDateTypeMatch = context -> matchDateTyped(TicketFields.CREATED); + final AggregationOperation dayProjection = context -> new Document("$project", new Document(ALIAS_DATE, new Document("$dateToString", new Document("format", "%Y-%m-%d") @@ -125,6 +131,7 @@ public List aggregateDailyTicketCounts(Server server, Date startD final Aggregation aggregation = Aggregation.newAggregation( Aggregation.match(criteria), + createdDateTypeMatch, dayProjection, Aggregation.group(ALIAS_DATE).count().as(ALIAS_COUNT), Aggregation.sort(Sort.Direction.ASC, "_id") @@ -142,7 +149,7 @@ public Document aggregatePunishmentAnalytics(Server server, Date startDate, Stri } pipeline.add(new Document("$unwind", "$" + PlayerFields.PUNISHMENTS)); - pipeline.add(new Document("$match", new Document(PlayerFields.PUNISHMENT_ISSUED, new Document("$type", "date")))); + pipeline.add(matchDateTyped(PlayerFields.PUNISHMENT_ISSUED)); if (startDate != null) { pipeline.add(new Document("$match", new Document(PlayerFields.PUNISHMENT_ISSUED, new Document("$gte", startDate)))); @@ -218,6 +225,7 @@ public Document aggregatePlayerActivity(Server server, Date startDate, String ti final List newPlayerFacet = new ArrayList<>(); newPlayerFacet.add(new Document("$addFields", new Document(EARLIEST_FIRST_LOGIN, new Document("$min", "$" + PlayerFields.IP_FIRST_LOGIN)))); + newPlayerFacet.add(matchDateTyped(EARLIEST_FIRST_LOGIN)); if (startDate != null) { newPlayerFacet.add(new Document("$match", new Document(EARLIEST_FIRST_LOGIN, diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java index f3bc8aa..0babe62 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/InvitationMongoRepository.java @@ -1,54 +1,54 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.InvitationFields; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Invitation; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class InvitationMongoRepository extends AbstractServerMongoRepository { - public InvitationMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Invitation.class, CollectionName.INVITATIONS, tenantMongoAccess); - } - - public List findActiveInvitations(Server server, Date now) { - return find(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); - } - - public long countActive(Server server, Date now) { - return count(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); - } - - public boolean existsByEmailActive(Server server, String email, Date now) { - return exists(server, Query.query( - Criteria.where(InvitationFields.EMAIL).is(email) - .and(InvitationFields.EXPIRES_AT).gt(now) - )); - } - - public Optional findByToken(Server server, String token) { - return findOne(server, Query.query(Criteria.where(InvitationFields.TOKEN).is(token))); - } - - public boolean deleteById(Server server, String id) { - return remove(server, Query.query(Criteria.where(InvitationFields.ID).is(id))).getDeletedCount() > 0; - } - - public void refreshToken(Server server, String invitationId, String newToken, Date newExpiresAt, Date updatedAt) { - Query query = Query.query(Criteria.where(InvitationFields.ID).is(invitationId)); - Update update = new Update(); - update.set(InvitationFields.TOKEN, newToken); - update.set(InvitationFields.EXPIRES_AT, newExpiresAt); - update.set(InvitationFields.UPDATED_AT, updatedAt); - updateFirst(server, query, update); - } -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.InvitationFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Invitation; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class InvitationMongoRepository extends AbstractServerMongoRepository { + public InvitationMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Invitation.class, CollectionName.INVITATIONS, tenantMongoAccess); + } + + public List findActiveInvitations(Server server, Date now) { + return find(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); + } + + public long countActive(Server server, Date now) { + return count(server, Query.query(Criteria.where(InvitationFields.EXPIRES_AT).gt(now))); + } + + public boolean existsByEmailActive(Server server, String email, Date now) { + return exists(server, Query.query( + Criteria.where(InvitationFields.EMAIL).is(email) + .and(InvitationFields.EXPIRES_AT).gt(now) + )); + } + + public Optional findByToken(Server server, String token) { + return findOne(server, Query.query(Criteria.where(InvitationFields.TOKEN).is(token))); + } + + public boolean deleteById(Server server, String id) { + return remove(server, Query.query(Criteria.where(InvitationFields.ID).is(id))).getDeletedCount() > 0; + } + + public void refreshToken(Server server, String invitationId, String newToken, Date newExpiresAt, Date updatedAt) { + Query query = Query.query(Criteria.where(InvitationFields.ID).is(invitationId)); + Update update = new Update(); + update.set(InvitationFields.TOKEN, newToken); + update.set(InvitationFields.EXPIRES_AT, newExpiresAt); + update.set(InvitationFields.UPDATED_AT, updatedAt); + updateFirst(server, query, update); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java index bdfd3d9..456d3af 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/MigrationMongoRepository.java @@ -1,109 +1,109 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.MigrationStatusFields; -import gg.modl.backend.migration.data.MigrationStatus; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class MigrationMongoRepository extends AbstractServerMongoRepository { - private static final String COLLECTION_NAME = "migrations"; - private static final List ACTIVE_STATUSES = - List.of("building_json", "uploading_json", "processing_data"); - - public MigrationMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(MigrationStatus.class, COLLECTION_NAME, tenantMongoAccess); - } - - public Optional findLatest(Server server) { - Query query = new Query().with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); - return findOne(server, query); - } - - public Optional findLatestCompletedOrFailed(Server server) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in("completed", "failed").and(MigrationStatusFields.COOLDOWN_EXEMPT).ne(true)) - .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.COMPLETED_AT)).limit(1); - return findOne(server, query); - } - - public Optional findActiveMigration(Server server) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)) - .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); - return findOne(server, query); - } - - public Optional findActiveMigration(Server server, Date staleBefore) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)) - .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); - return findOne(server, query); - } - - public boolean existsActiveMigration(Server server) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)); - return exists(server, query); - } - - public boolean existsActiveMigration(Server server, Date staleBefore) { - Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)); - return exists(server, query); - } - - public long failStaleMigrations(Server server, Date staleBefore, Date now, String message) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES), - new Criteria().orOperator( - Criteria.where(MigrationStatusFields.STARTED_AT).lt(staleBefore), - Criteria.where(MigrationStatusFields.STARTED_AT).exists(false) - ) - )); - Update update = new Update() - .set(MigrationStatusFields.STATUS, "failed") - .set(MigrationStatusFields.COMPLETED_AT, now) - .set(MigrationStatusFields.ERROR, message) - .set(MigrationStatusFields.PROGRESS_MESSAGE, message) - .set(MigrationStatusFields.COOLDOWN_EXEMPT, true); - return updateMulti(server, query, update).getModifiedCount(); - } - - public void cancelMigration(Server server, String id, String error, Date completedAt, - String progressMessage, boolean cooldownExempt) { - Update update = new Update() - .set(MigrationStatusFields.STATUS, "failed") - .set(MigrationStatusFields.ERROR, error) - .set(MigrationStatusFields.COMPLETED_AT, completedAt) - .set(MigrationStatusFields.PROGRESS_MESSAGE, progressMessage) - .set(MigrationStatusFields.COOLDOWN_EXEMPT, cooldownExempt); - updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); - } - - public void updateProgress(Server server, String id, String status, String message, - Integer recordsProcessed, Integer recordsSkipped, - Integer totalRecords, Date completedAt) { - Update update = new Update() - .set(MigrationStatusFields.STATUS, status) - .set(MigrationStatusFields.PROGRESS_MESSAGE, message); - - if (recordsProcessed != null) { - update.set(MigrationStatusFields.PROGRESS_RECORDS_PROCESSED, recordsProcessed); - } - if (recordsSkipped != null) { - update.set(MigrationStatusFields.PROGRESS_RECORDS_SKIPPED, recordsSkipped); - } - if (totalRecords != null) { - update.set(MigrationStatusFields.PROGRESS_TOTAL_RECORDS, totalRecords); - } - if (completedAt != null) { - update.set(MigrationStatusFields.COMPLETED_AT, completedAt); - } - updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); - } -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.MigrationStatusFields; +import gg.modl.backend.migration.data.MigrationStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class MigrationMongoRepository extends AbstractServerMongoRepository { + private static final String COLLECTION_NAME = "migrations"; + private static final List ACTIVE_STATUSES = + List.of("building_json", "uploading_json", "processing_data"); + + public MigrationMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(MigrationStatus.class, COLLECTION_NAME, tenantMongoAccess); + } + + public Optional findLatest(Server server) { + Query query = new Query().with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); + return findOne(server, query); + } + + public Optional findLatestCompletedOrFailed(Server server) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in("completed", "failed").and(MigrationStatusFields.COOLDOWN_EXEMPT).ne(true)) + .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.COMPLETED_AT)).limit(1); + return findOne(server, query); + } + + public Optional findActiveMigration(Server server) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)) + .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); + return findOne(server, query); + } + + public Optional findActiveMigration(Server server, Date staleBefore) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)) + .with(Sort.by(Sort.Direction.DESC, MigrationStatusFields.STARTED_AT)).limit(1); + return findOne(server, query); + } + + public boolean existsActiveMigration(Server server) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES)); + return exists(server, query); + } + + public boolean existsActiveMigration(Server server, Date staleBefore) { + Query query = Query.query(Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES).and(MigrationStatusFields.STARTED_AT).gte(staleBefore)); + return exists(server, query); + } + + public long failStaleMigrations(Server server, Date staleBefore, Date now, String message) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(MigrationStatusFields.STATUS).in(ACTIVE_STATUSES), + new Criteria().orOperator( + Criteria.where(MigrationStatusFields.STARTED_AT).lt(staleBefore), + Criteria.where(MigrationStatusFields.STARTED_AT).exists(false) + ) + )); + Update update = new Update() + .set(MigrationStatusFields.STATUS, "failed") + .set(MigrationStatusFields.COMPLETED_AT, now) + .set(MigrationStatusFields.ERROR, message) + .set(MigrationStatusFields.PROGRESS_MESSAGE, message) + .set(MigrationStatusFields.COOLDOWN_EXEMPT, true); + return updateMulti(server, query, update).getModifiedCount(); + } + + public void cancelMigration(Server server, String id, String error, Date completedAt, + String progressMessage, boolean cooldownExempt) { + Update update = new Update() + .set(MigrationStatusFields.STATUS, "failed") + .set(MigrationStatusFields.ERROR, error) + .set(MigrationStatusFields.COMPLETED_AT, completedAt) + .set(MigrationStatusFields.PROGRESS_MESSAGE, progressMessage) + .set(MigrationStatusFields.COOLDOWN_EXEMPT, cooldownExempt); + updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); + } + + public void updateProgress(Server server, String id, String status, String message, + Integer recordsProcessed, Integer recordsSkipped, + Integer totalRecords, Date completedAt) { + Update update = new Update() + .set(MigrationStatusFields.STATUS, status) + .set(MigrationStatusFields.PROGRESS_MESSAGE, message); + + if (recordsProcessed != null) { + update.set(MigrationStatusFields.PROGRESS_RECORDS_PROCESSED, recordsProcessed); + } + if (recordsSkipped != null) { + update.set(MigrationStatusFields.PROGRESS_RECORDS_SKIPPED, recordsSkipped); + } + if (totalRecords != null) { + update.set(MigrationStatusFields.PROGRESS_TOTAL_RECORDS, totalRecords); + } + if (completedAt != null) { + update.set(MigrationStatusFields.COMPLETED_AT, completedAt); + } + updateFirst(server, Query.query(Criteria.where(MigrationStatusFields.ID).is(id)), update); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java index 89abd76..409c251 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/PlayerMongoRepository.java @@ -1,263 +1,263 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.PlayerFields; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.server.data.Server; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.regex.Pattern; -import org.springframework.data.mongodb.core.BulkOperations; -import org.springframework.data.mongodb.core.query.Collation; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class PlayerMongoRepository extends AbstractServerMongoRepository { - public PlayerMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); - } - - public Optional findByMinecraftUuid(Server server, UUID minecraftUuid) { - return findByMinecraftUuid(server, minecraftUuid.toString()); - } - - public Optional findByMinecraftUuid(Server server, String minecraftUuid) { - return findOne(server, Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid))); - } - - public Optional findByUsernameIgnoreCase(Server server, String username) { - Query query = Query.query(Criteria.where(PlayerFields.USERNAME).is(username.trim())) - .collation(Collation.of("en").strength(2)); - return findOne(server, query); - } - - public List searchByUsernamePattern(Server server, String searchTerm, int limit) { - Pattern pattern = Pattern.compile(Pattern.quote(searchTerm), Pattern.CASE_INSENSITIVE); - Query query = Query.query(Criteria.where(PlayerFields.USERNAME).regex(pattern)); - query.limit(limit); - return find(server, query); - } - - public List findOnlinePlayers(Server server, int limit) { - Query query = Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true)); - query.limit(limit); - return find(server, query); - } - - private static final int DEFAULT_QUERY_LIMIT = 1000; - - public List findByMinecraftUuids(Server server, Collection minecraftUuids) { - return findByMinecraftUuids(server, minecraftUuids, DEFAULT_QUERY_LIMIT); - } - - public List findByMinecraftUuids(Server server, Collection minecraftUuids, int limit) { - if (minecraftUuids == null || minecraftUuids.isEmpty()) { - return List.of(); - } - - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); - query.limit(limit); - return find(server, query); - } - - public List findByIpAddresses(Server server, Collection ipAddresses) { - return findByIpAddresses(server, ipAddresses, DEFAULT_QUERY_LIMIT); - } - - public List findByIpAddresses(Server server, Collection ipAddresses, int limit) { - if (ipAddresses == null || ipAddresses.isEmpty()) { - return List.of(); - } - - Query query = Query.query(Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses)); - query.limit(limit); - return find(server, query); - } - - public List findByIpAddressesExcludingUuid(Server server, Collection ipAddresses, String excludedUuid, int limit) { - if (ipAddresses == null || ipAddresses.isEmpty()) { - return List.of(); - } - - Query query = Query.query(new Criteria().andOperator( - Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses), - Criteria.where(PlayerFields.MINECRAFT_UUID).ne(excludedUuid) - )); - query.limit(limit); - return find(server, query); - } - - public List findAvailablePlayers(Server server, Collection assignedUuids, int limit) { - Query query = new Query(); - if (assignedUuids != null && !assignedUuids.isEmpty()) { - query.addCriteria(Criteria.where(PlayerFields.MINECRAFT_UUID).nin(assignedUuids)); - } - query.limit(limit); - return find(server, query); - } - - public void updateLoginState(Server server, Player player) { - Update update = new Update() - .set(PlayerFields.USERNAMES, player.getUsernames()) - .set(PlayerFields.IP_ADDRESSES, player.getIpAddresses()) - .set(PlayerFields.DATA, player.getData()); - updateById(server, player.getId(), update); - } - - private void updateById(Server server, String playerId, Update update) { - Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId)); - updateFirst(server, query, update); - } - - public void replaceUsernames(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.USERNAMES, player.getUsernames())); - } - - public void replaceNotes(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.NOTES, player.getNotes())); - } - - public void replaceIpAddresses(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.IP_ADDRESSES, player.getIpAddresses())); - } - - public void replacePendingNotifications(Server server, Player player, List> notifications) { - updateById(server, player.getId(), new Update().set(PlayerFields.DATA_PENDING_NOTIFICATIONS, notifications)); - } - - public boolean pushPendingNotification(Server server, String minecraftUuid, Map notification) { - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update().push(PlayerFields.DATA_PENDING_NOTIFICATIONS, notification); - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public void addLinkedAccounts(Server server, String minecraftUuid, Collection linkedUuids, Date when) { - if (linkedUuids == null || linkedUuids.isEmpty()) { - return; - } - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update() - .addToSet(PlayerFields.DATA_LINKED_ACCOUNTS).each(linkedUuids.toArray()) - .set(PlayerFields.DATA_LAST_LINKED_UPDATE, when); - updateFirst(server, query, update); - } - - public void replaceData(Server server, Player player) { - updateById(server, player.getId(), new Update().set(PlayerFields.DATA, player.getData())); - } - - public void replaceLinkedAccounts(Server server, Player player) { - Map data = player.getData(); - Object linkedAccounts = data != null ? data.get("linkedAccounts") : null; - Object lastLinkedUpdate = data != null ? data.get("lastLinkedUpdate") : null; - - Update update = new Update().set(PlayerFields.DATA_LINKED_ACCOUNTS, linkedAccounts); - if (lastLinkedUpdate instanceof Date date) { - update.set(PlayerFields.DATA_LAST_LINKED_UPDATE, date); - } else { - update.unset(PlayerFields.DATA_LAST_LINKED_UPDATE); - } - updateById(server, player.getId(), update); - } - - public boolean markDisconnected(Server server, String minecraftUuid, long sessionDurationMs) { - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update() - .set(PlayerFields.DATA_IS_ONLINE, false) - .set(PlayerFields.DATA_LAST_LOGOUT, new Date()); - - if (sessionDurationMs > 0) { - update.inc(PlayerFields.DATA_TOTAL_PLAYTIME_SECONDS, sessionDurationMs / 1000); - } - - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public boolean updateLastServer(Server server, String minecraftUuid, String serverName) { - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update().set(PlayerFields.DATA_LAST_SERVER, serverName); - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public void markStalePlayersOffline(Server server, Collection onlineUuids, - String serverName, Date logoutTime) { - Criteria criteria = Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) - .and(PlayerFields.MINECRAFT_UUID).nin(onlineUuids); - if (serverName != null && !serverName.isBlank()) { - criteria = criteria.and(PlayerFields.DATA_LAST_SERVER).is(serverName); - } - Update update = new Update() - .set(PlayerFields.DATA_IS_ONLINE, false) - .set(PlayerFields.DATA_LAST_LOGOUT, logoutTime); - updateMulti(server, Query.query(criteria), update); - } - - public List findByMinecraftUuids(Server server, List minecraftUuids) { - if (minecraftUuids == null || minecraftUuids.isEmpty()) { - return List.of(); - } - Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); - query.limit(DEFAULT_QUERY_LIMIT); - return find(server, query); - } - - public BulkOperations bulkOps(Server server) { - return serverTemplate(server).bulkOps( - BulkOperations.BulkMode.UNORDERED, - Player.class, - CollectionName.PLAYERS - ); - } - - public void bulkMergeByUuid(Server server, Map updatesByUuid) { - if (updatesByUuid == null || updatesByUuid.isEmpty()) { - return; - } - BulkOperations ops = bulkOps(server); - for (Map.Entry entry : updatesByUuid.entrySet()) { - ops.updateOne( - Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(entry.getKey())), - entry.getValue() - ); - } - ops.execute(); - } - - public long countOnlinePlayers(Server server) { - return count(server, Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true))); - } - - public long countAll(Server server) { - return count(server, new Query()); - } - - public long countFirstJoinedAfter(Server server, Date after) { - return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(after))); - } - - public long countFirstJoinedBetween(Server server, Date from, Date to) { - return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(from).lt(to))); - } - - public long countOnlineByUuids(Server server, Collection uuids) { - if (uuids == null || uuids.isEmpty()) { - return 0; - } - Query query = Query.query(new Criteria().andOperator( - Criteria.where(PlayerFields.MINECRAFT_UUID).in(uuids), - Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) - )); - return count(server, query); - } - -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.PlayerFields; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.server.data.Server; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.data.mongodb.core.BulkOperations; +import org.springframework.data.mongodb.core.query.Collation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class PlayerMongoRepository extends AbstractServerMongoRepository { + public PlayerMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); + } + + public Optional findByMinecraftUuid(Server server, UUID minecraftUuid) { + return findByMinecraftUuid(server, minecraftUuid.toString()); + } + + public Optional findByMinecraftUuid(Server server, String minecraftUuid) { + return findOne(server, Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid))); + } + + public Optional findByUsernameIgnoreCase(Server server, String username) { + Query query = Query.query(Criteria.where(PlayerFields.USERNAME).is(username.trim())) + .collation(Collation.of("en").strength(2)); + return findOne(server, query); + } + + public List searchByUsernamePattern(Server server, String searchTerm, int limit) { + Pattern pattern = Pattern.compile(Pattern.quote(searchTerm), Pattern.CASE_INSENSITIVE); + Query query = Query.query(Criteria.where(PlayerFields.USERNAME).regex(pattern)); + query.limit(limit); + return find(server, query); + } + + public List findOnlinePlayers(Server server, int limit) { + Query query = Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true)); + query.limit(limit); + return find(server, query); + } + + private static final int DEFAULT_QUERY_LIMIT = 1000; + + public List findByMinecraftUuids(Server server, Collection minecraftUuids) { + return findByMinecraftUuids(server, minecraftUuids, DEFAULT_QUERY_LIMIT); + } + + public List findByMinecraftUuids(Server server, Collection minecraftUuids, int limit) { + if (minecraftUuids == null || minecraftUuids.isEmpty()) { + return List.of(); + } + + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); + query.limit(limit); + return find(server, query); + } + + public List findByIpAddresses(Server server, Collection ipAddresses) { + return findByIpAddresses(server, ipAddresses, DEFAULT_QUERY_LIMIT); + } + + public List findByIpAddresses(Server server, Collection ipAddresses, int limit) { + if (ipAddresses == null || ipAddresses.isEmpty()) { + return List.of(); + } + + Query query = Query.query(Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses)); + query.limit(limit); + return find(server, query); + } + + public List findByIpAddressesExcludingUuid(Server server, Collection ipAddresses, String excludedUuid, int limit) { + if (ipAddresses == null || ipAddresses.isEmpty()) { + return List.of(); + } + + Query query = Query.query(new Criteria().andOperator( + Criteria.where(PlayerFields.IP_ADDRESS).in(ipAddresses), + Criteria.where(PlayerFields.MINECRAFT_UUID).ne(excludedUuid) + )); + query.limit(limit); + return find(server, query); + } + + public List findAvailablePlayers(Server server, Collection assignedUuids, int limit) { + Query query = new Query(); + if (assignedUuids != null && !assignedUuids.isEmpty()) { + query.addCriteria(Criteria.where(PlayerFields.MINECRAFT_UUID).nin(assignedUuids)); + } + query.limit(limit); + return find(server, query); + } + + public void updateLoginState(Server server, Player player) { + Update update = new Update() + .set(PlayerFields.USERNAMES, player.getUsernames()) + .set(PlayerFields.IP_ADDRESSES, player.getIpAddresses()) + .set(PlayerFields.DATA, player.getData()); + updateById(server, player.getId(), update); + } + + private void updateById(Server server, String playerId, Update update) { + Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId)); + updateFirst(server, query, update); + } + + public void replaceUsernames(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.USERNAMES, player.getUsernames())); + } + + public void replaceNotes(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.NOTES, player.getNotes())); + } + + public void replaceIpAddresses(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.IP_ADDRESSES, player.getIpAddresses())); + } + + public void replacePendingNotifications(Server server, Player player, List> notifications) { + updateById(server, player.getId(), new Update().set(PlayerFields.DATA_PENDING_NOTIFICATIONS, notifications)); + } + + public boolean pushPendingNotification(Server server, String minecraftUuid, Map notification) { + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update().push(PlayerFields.DATA_PENDING_NOTIFICATIONS, notification); + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public void addLinkedAccounts(Server server, String minecraftUuid, Collection linkedUuids, Date when) { + if (linkedUuids == null || linkedUuids.isEmpty()) { + return; + } + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update() + .addToSet(PlayerFields.DATA_LINKED_ACCOUNTS).each(linkedUuids.toArray()) + .set(PlayerFields.DATA_LAST_LINKED_UPDATE, when); + updateFirst(server, query, update); + } + + public void replaceData(Server server, Player player) { + updateById(server, player.getId(), new Update().set(PlayerFields.DATA, player.getData())); + } + + public void replaceLinkedAccounts(Server server, Player player) { + Map data = player.getData(); + Object linkedAccounts = data != null ? data.get("linkedAccounts") : null; + Object lastLinkedUpdate = data != null ? data.get("lastLinkedUpdate") : null; + + Update update = new Update().set(PlayerFields.DATA_LINKED_ACCOUNTS, linkedAccounts); + if (lastLinkedUpdate instanceof Date date) { + update.set(PlayerFields.DATA_LAST_LINKED_UPDATE, date); + } else { + update.unset(PlayerFields.DATA_LAST_LINKED_UPDATE); + } + updateById(server, player.getId(), update); + } + + public boolean markDisconnected(Server server, String minecraftUuid, long sessionDurationMs) { + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update() + .set(PlayerFields.DATA_IS_ONLINE, false) + .set(PlayerFields.DATA_LAST_LOGOUT, new Date()); + + if (sessionDurationMs > 0) { + update.inc(PlayerFields.DATA_TOTAL_PLAYTIME_SECONDS, sessionDurationMs / 1000); + } + + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public boolean updateLastServer(Server server, String minecraftUuid, String serverName) { + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update().set(PlayerFields.DATA_LAST_SERVER, serverName); + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public void markStalePlayersOffline(Server server, Collection onlineUuids, + String serverName, Date logoutTime) { + Criteria criteria = Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) + .and(PlayerFields.MINECRAFT_UUID).nin(onlineUuids); + if (serverName != null && !serverName.isBlank()) { + criteria = criteria.and(PlayerFields.DATA_LAST_SERVER).is(serverName); + } + Update update = new Update() + .set(PlayerFields.DATA_IS_ONLINE, false) + .set(PlayerFields.DATA_LAST_LOGOUT, logoutTime); + updateMulti(server, Query.query(criteria), update); + } + + public List findByMinecraftUuids(Server server, List minecraftUuids) { + if (minecraftUuids == null || minecraftUuids.isEmpty()) { + return List.of(); + } + Query query = Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).in(minecraftUuids)); + query.limit(DEFAULT_QUERY_LIMIT); + return find(server, query); + } + + public BulkOperations bulkOps(Server server) { + return serverTemplate(server).bulkOps( + BulkOperations.BulkMode.UNORDERED, + Player.class, + CollectionName.PLAYERS + ); + } + + public void bulkMergeByUuid(Server server, Map updatesByUuid) { + if (updatesByUuid == null || updatesByUuid.isEmpty()) { + return; + } + BulkOperations ops = bulkOps(server); + for (Map.Entry entry : updatesByUuid.entrySet()) { + ops.updateOne( + Query.query(Criteria.where(PlayerFields.MINECRAFT_UUID).is(entry.getKey())), + entry.getValue() + ); + } + ops.execute(); + } + + public long countOnlinePlayers(Server server) { + return count(server, Query.query(Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true))); + } + + public long countAll(Server server) { + return count(server, new Query()); + } + + public long countFirstJoinedAfter(Server server, Date after) { + return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(after))); + } + + public long countFirstJoinedBetween(Server server, Date from, Date to) { + return count(server, Query.query(Criteria.where(PlayerFields.DATA_FIRST_JOIN).gte(from).lt(to))); + } + + public long countOnlineByUuids(Server server, Collection uuids) { + if (uuids == null || uuids.isEmpty()) { + return 0; + } + Query query = Query.query(new Criteria().andOperator( + Criteria.where(PlayerFields.MINECRAFT_UUID).in(uuids), + Criteria.where(PlayerFields.DATA_IS_ONLINE).is(true) + )); + return count(server, query); + } + +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java index e062ff8..969276d 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java @@ -1,1127 +1,1127 @@ -package gg.modl.backend.database.mongo.repository; - -import static gg.modl.backend.database.mongo.MongoAggregationResults.extractFacetCount; -import static gg.modl.backend.database.mongo.MongoAggregationResults.extractLong; - -import com.mongodb.client.result.UpdateResult; -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.ServerFields; -import gg.modl.backend.server.data.CustomDomainStatus; -import gg.modl.backend.server.data.ProvisioningStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.regex.Pattern; -import org.bson.Document; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.FindAndModifyOptions; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationExpression; -import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; -import org.springframework.data.mongodb.core.aggregation.DateOperators; -import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; -import org.springframework.data.mongodb.core.aggregation.SetOperation; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class ServerMongoRepository extends AbstractGlobalMongoRepository { - private static final String FILTER_ALL = "all"; - private static final String FILTER_ACTIVE = "active"; - private static final String FILTER_PENDING = "pending"; - private static final String FILTER_FAILED = "failed"; - private static final String FILTER_UNVERIFIED = "unverified"; - private static final String DOMAIN_STATUS_ACTIVE = "ACTIVE"; - - private static final String ALIAS_TOTAL = "total"; - private static final String ALIAS_TOTAL_USERS = "totalUsers"; - private static final String ALIAS_TOTAL_TICKETS = "totalTickets"; - private static final String ALIAS_VALUE = "value"; - private static final String ALIAS_NAME = "name"; - private static final String ALIAS_DATE = "date"; - private static final String ALIAS_SERVERS = "servers"; - private static final String ALIAS_VALUE_SOURCE = "valueSource"; - - private static final String METRIC_USERS = "users"; - private static final String METRIC_TICKETS = "tickets"; - - private static final String ORDER_ASC = "asc"; - private static final String INVALID_PLAN_SENTINEL = "__invalid_plan__"; - private static final String RESET_MESSAGE = "Database reset - awaiting reprovisioning"; - - private static final Set ADMIN_SORT_FIELDS = Set.of( - ServerFields.SERVER_NAME, - ServerFields.CUSTOM_DOMAIN, - ServerFields.ADMIN_EMAIL, - ServerFields.PLAN, - ServerFields.CREATED_AT, - ServerFields.UPDATED_AT, - ServerFields.USER_COUNT, - ServerFields.PROVISIONING_STATUS, - ServerFields.LAST_ACTIVITY_AT - ); - - public ServerMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); - } - - public long countAll() { - return count(new Query()); - } - - public Optional findByCustomDomain(String customDomain) { - return findOne(Query.query(Criteria.where(ServerFields.CUSTOM_DOMAIN).is(customDomain))); - } - - public Optional findByActiveCustomDomainOverride(String domain) { - Criteria criteria = new Criteria().andOperator( - Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(domain), - Criteria.where(ServerFields.CUSTOM_DOMAIN_STATUS).is(DOMAIN_STATUS_ACTIVE) - ); - return findOne(new Query(criteria)); - } - - public Optional findMatchingIdentity(String email, String serverName, String subdomain) { - Criteria criteria = new Criteria().orOperator( - Criteria.where(ServerFields.ADMIN_EMAIL).is(email), - Criteria.where(ServerFields.SERVER_NAME).is(serverName), - Criteria.where(ServerFields.CUSTOM_DOMAIN).is(subdomain) - ); - return findOne(new Query(criteria)); - } - - public Optional findByDatabaseName(String databaseName) { - return findOne(Query.query(Criteria.where(ServerFields.DATABASE_NAME).is(databaseName))); - } - - public Optional findByApiKey(String apiKey) { - return findOne(Query.query(Criteria.where(ServerFields.API_KEY).is(apiKey))); - } - - public boolean existsByAdminEmailExcludingId(String adminEmail, String excludedServerId) { - Criteria criteria = Criteria.where(ServerFields.ADMIN_EMAIL) - .regex("^" + Pattern.quote(adminEmail) + "$", "i") - .and(ServerFields.ID).ne(excludedServerId); - return exists(Query.query(criteria)); - } - - public Optional findByEmailVerificationToken(String token) { - return findOne(Query.query(Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token))); - } - - public Optional verifyEmailTokenAtomically(String token) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), - noCleanupClaimCriteria() - )); - - Update update = new Update() - .set(ServerFields.EMAIL_VERIFIED, true) - .unset(ServerFields.EMAIL_VERIFICATION_TOKEN) - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) - .set(ServerFields.UPDATED_AT, new Date()); - - return Optional.ofNullable(findAndModify( - query, - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public Optional findByProvisioningSignInToken(String token) { - return findOne(Query.query(Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token))); - } - - public Optional consumeProvisioningSignInToken(String token, Date now) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token), - Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT).gt(now), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED) - )); - Update update = new Update() - .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN) - .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT) - .set(ServerFields.UPDATED_AT, now); - return Optional.ofNullable(findAndModify(query, update, FindAndModifyOptions.options().returnNew(true))); - } - - public Optional findByCliSetupToken(String token) { - return findOne(Query.query(Criteria.where(ServerFields.CLI_SETUP_TOKEN).is(token))); - } - - public Optional findByStripeCustomerId(String customerId) { - if (customerId == null) { - return Optional.empty(); - } - return findOne(Query.query(Criteria.where(ServerFields.STRIPE_CUSTOMER_ID).is(customerId))); - } - - public Optional findByStripeSubscriptionId(String subscriptionId) { - return findOne(Query.query(Criteria.where(ServerFields.STRIPE_SUBSCRIPTION_ID).is(subscriptionId))); - } - - public long countCompletedAndVerified() { - return count(Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true) - ))); - } - - public long countByProvisioningStatus(ProvisioningStatus status) { - return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).is(status))); - } - - public long countByProvisioningStatuses(ProvisioningStatus... statuses) { - return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).in((Object[]) statuses))); - } - - public long countActiveSince(Date activityCutoff) { - return count(Query.query(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff))); - } - - public long countCompletedWithUsers() { - return count(Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.USER_COUNT).gt(0) - ))); - } - - public long countCreatedSince(Date startDate) { - return count(Query.query(Criteria.where(ServerFields.CREATED_AT).gte(startDate))); - } - - public long countCreatedBetween(Date startDate, Date endDate) { - return count(Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.CREATED_AT).gte(startDate), - Criteria.where(ServerFields.CREATED_AT).lt(endDate) - ))); - } - - public long sumOnlinePlayersSince(Date activityCutoff) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff)), - Aggregation.group().sum(ServerFields.ONLINE_PLAYER_COUNT).as(ALIAS_TOTAL) - ); - Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); - return extractLong(result, ALIAS_TOTAL); - } - - public UsageTotals getUsageTotals() { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group() - .sum(ServerFields.USER_COUNT).as(ALIAS_TOTAL_USERS) - .sum(ServerFields.TICKET_COUNT).as(ALIAS_TOTAL_TICKETS) - ); - Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); - return new UsageTotals( - extractLong(result, ALIAS_TOTAL_USERS), - extractLong(result, ALIAS_TOTAL_TICKETS) - ); - } - - public List aggregatePlanCounts() { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(ServerFields.PLAN).count().as(ALIAS_VALUE), - Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, NameValueResult.class).getMappedResults(); - } - - public List aggregateProvisioningStatusCounts() { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(ServerFields.PROVISIONING_STATUS).count().as(ALIAS_VALUE), - Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, NameValueResult.class).getMappedResults(); - } - - public List findRegistrationTrend(Date startDate) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), - Aggregation.project() - .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE), - Aggregation.group(ALIAS_DATE).count().as(ALIAS_SERVERS), - Aggregation.sort(Sort.Direction.ASC, "_id"), - Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_SERVERS).as(ALIAS_SERVERS) - ); - return aggregate(aggregation, DateServersResult.class).getMappedResults(); - } - - public List findTopCompletedVerifiedByUserCount(int limit) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), - Criteria.where(ServerFields.USER_COUNT).gt(0) - )); - query.with(Sort.by(Sort.Direction.DESC, ServerFields.USER_COUNT)); - query.limit(limit); - return find(query); - } - - public List findAdminServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { - Query query = buildAdminServerFilterQuery(search, plan, status); - query.with(Sort.by(resolveSortDirection(sortOrder), resolveAdminSortField(sortField))); - query.skip(skip).limit(limit); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.PLAN) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.PROVISIONING_STATUS) - .include(ServerFields.CREATED_AT) - .include(ServerFields.UPDATED_AT) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_ACTIVITY_AT); - return find(query); - } - - private Query buildAdminServerFilterQuery(String search, String plan, String status) { - Query query = new Query(); - List criteriaList = new ArrayList<>(); - - if (search != null && !search.trim().isEmpty()) { - String escapedSearch = Pattern.quote(search.trim()); - criteriaList.add(new Criteria().orOperator( - Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), - Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), - Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") - )); - } - - if (plan != null && !FILTER_ALL.equals(plan)) { - try { - criteriaList.add(Criteria.where(ServerFields.PLAN).is(ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)))); - } catch (IllegalArgumentException ignored) { - criteriaList.add(Criteria.where(ServerFields.PLAN).is(INVALID_PLAN_SENTINEL)); - } - } - - if (status != null && !FILTER_ALL.equals(status)) { - switch (status) { - case FILTER_ACTIVE -> { - criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED)); - criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(true)); - } - case FILTER_PENDING -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS) - .in(ProvisioningStatus.PENDING, ProvisioningStatus.IN_PROGRESS)); - case FILTER_FAILED -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.FAILED)); - case FILTER_UNVERIFIED -> criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(false)); - default -> { - } - } - } - - if (!criteriaList.isEmpty()) { - query.addCriteria(new Criteria().andOperator(criteriaList.toArray(new Criteria[0]))); - } - return query; - } - - private Sort.Direction resolveSortDirection(String sortOrder) { - return ORDER_ASC.equalsIgnoreCase(sortOrder) ? Sort.Direction.ASC : Sort.Direction.DESC; - } - - private String resolveAdminSortField(String sortField) { - return ADMIN_SORT_FIELDS.contains(sortField) ? sortField : ServerFields.CREATED_AT; - } - - public long countAdminServers(String search, String plan, String status) { - return count(buildAdminServerFilterQuery(search, plan, status)); - } - - public List findUsageRefreshCandidates(Date staleCutoff, int limit) { - Criteria staleCriteria = new Criteria().orOperator( - Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).exists(false), - Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).lt(staleCutoff) - ); - - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.DATABASE_NAME).ne(null), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), - Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), - staleCriteria - )); - query.with(Sort.by(Sort.Direction.ASC, ServerFields.LAST_STATS_UPDATED_AT)); - query.limit(limit); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.DATABASE_NAME) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.PLAN) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_STATS_UPDATED_AT) - .include(ServerFields.LAST_ACTIVITY_AT) - .include(ServerFields.UPDATED_AT); - return find(query); - } - - public List findExpiredRegistrationCleanupCandidates(Date cutoff, int limit) { - return findExpiredRegistrationCleanupCandidates(cutoff, new Date(0), limit); - } - - public List findExpiredRegistrationCleanupCandidates(Date cutoff, Date claimCutoff, int limit) { - Query query = Query.query(expiredRegistrationCriteria(cutoff, claimCutoff)); - query.with(Sort.by(Sort.Direction.ASC, ServerFields.CREATED_AT)); - query.limit(limit); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.DATABASE_NAME) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.EMAIL_VERIFICATION_TOKEN) - .include(ServerFields.PROVISIONING_STATUS) - .include(ServerFields.API_KEY) - .include(ServerFields.ONLINE_PLAYER_COUNT) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_ACTIVITY_AT) - .include(ServerFields.CREATED_AT) - .include(ServerFields.UPDATED_AT) - .include(ServerFields.CLEANUP_CLAIM_ID) - .include(ServerFields.CLEANUP_CLAIMED_AT); - return find(query); - } - - public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Instant claimedAt) { - return claimExpiredRegistrationForCleanup(serverId, cutoff, new Date(0), claimedAt); - } - - public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Date claimCutoff, Instant claimedAt) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - expiredRegistrationCriteria(cutoff, claimCutoff) - )); - Update update = new Update() - .set(ServerFields.CLEANUP_CLAIM_ID, UUID.randomUUID().toString()) - .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(claimedAt)) - .set(ServerFields.UPDATED_AT, Date.from(claimedAt)); - - return Optional.ofNullable(findAndModify( - query, - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public boolean deleteClaimedExpiredRegistration(String serverId, String cleanupClaimId, Date cutoff) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), - explicitExpiredRegistrationCriteria(cutoff) - )); - return remove(query).getDeletedCount() > 0; - } - - public Optional confirmRegistrationCleanupClaim(String serverId, String cleanupClaimId, Date cutoff, Instant confirmedAt) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), - explicitExpiredRegistrationCriteria(cutoff) - )); - Update update = new Update() - .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(confirmedAt)) - .set(ServerFields.UPDATED_AT, Date.from(confirmedAt)); - - return Optional.ofNullable(findAndModify( - query, - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public boolean releaseRegistrationCleanupClaim(String serverId, String cleanupClaimId) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId) - )); - Update update = new Update() - .unset(ServerFields.CLEANUP_CLAIM_ID) - .unset(ServerFields.CLEANUP_CLAIMED_AT) - .set(ServerFields.UPDATED_AT, new Date()); - return updateFirst(query, update).getModifiedCount() > 0; - } - - public List findUsageTargetsByIds(List serverIds) { - Query query = Query.query(Criteria.where(ServerFields.ID).in(serverIds)); - query.fields() - .include(ServerFields.SERVER_NAME) - .include(ServerFields.CUSTOM_DOMAIN) - .include(ServerFields.DATABASE_NAME) - .include(ServerFields.ADMIN_EMAIL) - .include(ServerFields.EMAIL_VERIFIED) - .include(ServerFields.PLAN) - .include(ServerFields.USER_COUNT) - .include(ServerFields.TICKET_COUNT) - .include(ServerFields.LAST_STATS_UPDATED_AT) - .include(ServerFields.LAST_ACTIVITY_AT) - .include(ServerFields.UPDATED_AT); - return find(query); - } - - private Criteria expiredRegistrationCriteria(Date cutoff, Date claimCutoff) { - return new Criteria().andOperator( - explicitExpiredRegistrationCriteria(cutoff), - cleanupClaimEligibleCriteria(claimCutoff) - ); - } - - private Criteria explicitExpiredRegistrationCriteria(Date cutoff) { - return new Criteria().andOperator( - Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), - Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), - Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).exists(true).nin(null, ""), - Criteria.where(ServerFields.CREATED_AT).exists(true).lt(cutoff), - Criteria.where(ServerFields.DATABASE_NAME).regex("^server_.+"), - notPresentOrBlank(ServerFields.API_KEY), - notPresent(ServerFields.LAST_ACTIVITY_AT), - notPositive(ServerFields.USER_COUNT), - notPositive(ServerFields.TICKET_COUNT), - notPositive(ServerFields.ONLINE_PLAYER_COUNT) - ); - } - - private Criteria cleanupClaimEligibleCriteria(Date claimCutoff) { - return new Criteria().orOperator( - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null), - Criteria.where(ServerFields.CLEANUP_CLAIMED_AT).lt(claimCutoff) - ); - } - - private Criteria noCleanupClaimCriteria() { - return new Criteria().orOperator( - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), - Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null) - ); - } - - private Criteria notPresent(String field) { - return new Criteria().orOperator( - Criteria.where(field).exists(false), - Criteria.where(field).is(null) - ); - } - - private Criteria notPresentOrBlank(String field) { - return new Criteria().orOperator( - Criteria.where(field).exists(false), - Criteria.where(field).is(null), - Criteria.where(field).is("") - ); - } - - private Criteria notPositive(String field) { - return new Criteria().orOperator( - Criteria.where(field).exists(false), - Criteria.where(field).is(null), - Criteria.where(field).lte(0) - ); - } - - public List findProvisioningCandidatesByIds(List serverIds) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).in(serverIds), - Criteria.where(ServerFields.DATABASE_NAME).exists(true).ne(null) - )); - return find(query); - } - - public List findCancelledWithPeriodEnd() { - Criteria criteria = new Criteria().andOperator( - Criteria.where(ServerFields.SUBSCRIPTION_STATUS).is(SubscriptionStatus.CANCELED), - Criteria.where(ServerFields.CURRENT_PERIOD_END).exists(true).ne(null) - ); - return find(new Query(criteria)); - } - - public void incrementAiRequests(String serverId, long additionalRequests) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().inc(ServerFields.AI_REQUESTS_CURRENT_PERIOD, additionalRequests) - ); - } - - public void incrementStorageUsed(String serverId, long bytes) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes) - ); - } - - public boolean tryIncrementStorageUsedWithinLimit(String serverId, long bytes, long maxBytes) { - long maxCurrentBytes = maxBytes - bytes; - if (bytes < 0 || maxCurrentBytes < 0) { - return false; - } - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - new Criteria().orOperator( - Criteria.where(ServerFields.STORAGE_USED_BYTES).lte(maxCurrentBytes), - Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false), - Criteria.where(ServerFields.STORAGE_USED_BYTES).is(null) - ) - )); - UpdateResult result = updateFirst(query, new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes)); - return result.getMatchedCount() == 1; - } - - public void decrementStorageUsed(String serverId, long bytes) { - AggregationUpdate update = AggregationUpdate.update().set( - SetOperation.set(ServerFields.STORAGE_USED_BYTES).toValueOf(flooredStorageAfterDecrement(bytes)) - ); - globalTemplate().updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - collectionName() - ); - } - - private AggregationExpression flooredStorageAfterDecrement(long bytes) { - return context -> new Document("$max", List.of(0L, new Document("$subtract", - List.of("$" + ServerFields.STORAGE_USED_BYTES, bytes)))); - } - - public void setStorageUsed(String serverId, long bytes) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().set(ServerFields.STORAGE_USED_BYTES, bytes) - ); - } - - public boolean setStorageUsedIfBelow(String serverId, long bytes) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(ServerFields.ID).is(serverId), - new Criteria().orOperator( - Criteria.where(ServerFields.STORAGE_USED_BYTES).lt(bytes), - Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false) - ) - )); - UpdateResult result = updateFirst(query, new Update().set(ServerFields.STORAGE_USED_BYTES, bytes)); - return result.getModifiedCount() == 1; - } - - public Optional findAIUsageSnapshotById(String serverId) { - Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId)); - query.fields() - .include(ServerFields.AI_REQUESTS_CURRENT_PERIOD) - .include(ServerFields.MAX_AI_OVERAGE_REQUESTS); - - Document document = globalTemplate().findOne(query, Document.class, collectionName()); - if (document == null) { - return Optional.empty(); - } - - return Optional.of(new AIUsageSnapshot( - extractLong(document, ServerFields.AI_REQUESTS_CURRENT_PERIOD), - extractLong(document, ServerFields.MAX_AI_OVERAGE_REQUESTS) - )); - } - - public void resetUsageCounters(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) - ); - } - - public void resetUsageAndStatsCounters(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.STORAGE_USED_BYTES, 0L) - .set(ServerFields.USER_COUNT, 0L) - .set(ServerFields.TICKET_COUNT, 0L) - .set(ServerFields.ONLINE_PLAYER_COUNT, 0L) - .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public List findBetaTesters(String search, int skip, int limit) { - Query query = buildBetaTesterQuery(search); - query.with(Sort.by(Sort.Direction.DESC, ServerFields.BETA_TESTER_CREATED_AT)); - query.skip(skip).limit(limit); - return find(query); - } - - public long countBetaTesters(String search) { - return count(buildBetaTesterQuery(search)); - } - - public List findAllBetaTesters() { - return find(Query.query(Criteria.where(ServerFields.BETA_TESTER).is(true))); - } - - private Query buildBetaTesterQuery(String search) { - Criteria betaCriteria = Criteria.where(ServerFields.BETA_TESTER_CREATED_AT).exists(true); - if (search == null || search.trim().isEmpty()) { - return new Query(betaCriteria); - } - String escapedSearch = Pattern.quote(search.trim()); - return new Query(new Criteria().andOperator( - betaCriteria, - new Criteria().orOperator( - Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), - Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), - Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") - ) - )); - } - - public Optional updateBetaState(String serverId, ServerPlan plan, SubscriptionStatus subscriptionStatus, boolean betaTester) { - Update update = new Update() - .set(ServerFields.PLAN, plan) - .set(ServerFields.SUBSCRIPTION_STATUS, subscriptionStatus) - .set(ServerFields.BETA_TESTER, betaTester) - .set(ServerFields.UPDATED_AT, new Date()); - return Optional.ofNullable(findAndModify( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public void updateAdminEmail(String serverId, String adminEmail) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.ADMIN_EMAIL, adminEmail) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updateApiKey(String serverId, String apiKey) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().set(ServerFields.API_KEY, apiKey) - ); - } - - public void clearApiKey(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update().unset(ServerFields.API_KEY) - ); - } - - public Optional updateAllowedFields(String serverId, Map updateData) { - Update update = new Update(); - boolean hasChanges = false; - - for (Map.Entry entry : updateData.entrySet()) { - String key = entry.getKey(); - Object value = entry.getValue(); - if (value == null) { - continue; - } - - switch (key) { - case ServerFields.ADMIN_EMAIL -> { - update.set(ServerFields.ADMIN_EMAIL, value); - hasChanges = true; - } - case ServerFields.EMAIL_VERIFIED -> { - update.set(ServerFields.EMAIL_VERIFIED, value); - hasChanges = true; - } - case ServerFields.PROVISIONING_STATUS -> { - update.set(ServerFields.PROVISIONING_STATUS, normalizeProvisioningStatus(value)); - hasChanges = true; - } - case ServerFields.PROVISIONING_NOTES -> { - update.set(ServerFields.PROVISIONING_NOTES, value); - hasChanges = true; - } - case ServerFields.PLAN -> { - update.set(ServerFields.PLAN, normalizePlan(value)); - hasChanges = true; - } - case ServerFields.SUBSCRIPTION_STATUS -> { - update.set(ServerFields.SUBSCRIPTION_STATUS, normalizeSubscriptionStatus(value)); - hasChanges = true; - } - case ServerFields.LAST_ACTIVITY_AT -> { - update.set(ServerFields.LAST_ACTIVITY_AT, normalizeDate(value)); - hasChanges = true; - } - case ServerFields.UPDATED_AT -> { - update.set(ServerFields.UPDATED_AT, normalizeDate(value)); - hasChanges = true; - } - default -> { - } - } - } - - if (!hasChanges) { - return findById(serverId); - } - - Server updated = findAndModify( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - FindAndModifyOptions.options().returnNew(true) - ); - return Optional.ofNullable(updated); - } - - private ServerPlan normalizePlan(Object value) { - if (value instanceof ServerPlan plan) { - return plan; - } - return ServerPlan.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); - } - - private ProvisioningStatus normalizeProvisioningStatus(Object value) { - if (value instanceof ProvisioningStatus provisioningStatus) { - return provisioningStatus; - } - return ProvisioningStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); - } - - private SubscriptionStatus normalizeSubscriptionStatus(Object value) { - if (value instanceof SubscriptionStatus subscriptionStatus) { - return subscriptionStatus; - } - return SubscriptionStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); - } - - private Date normalizeDate(Object value) { - if (value instanceof Date d) { - return d; - } - if (value instanceof Instant i) { - return Date.from(i); - } - if (value instanceof Number n) { - return new Date(n.longValue()); - } - if (value instanceof String s) { - return Date.from(Instant.parse(s.trim())); - } - throw new IllegalArgumentException("Unsupported value type for date field: " - + (value == null ? "null" : value.getClass())); - } - - public boolean deleteByServerId(String serverId) { - return remove(Query.query(Criteria.where(ServerFields.ID).is(serverId))).getDeletedCount() > 0; - } - - public long deleteByServerIds(List serverIds) { - return remove(Query.query(Criteria.where(ServerFields.ID).in(serverIds))).getDeletedCount(); - } - - public long bulkSuspend(List serverIds, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) - .set(ServerFields.UPDATED_AT, updatedAt); - return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); - } - - public long bulkActivate(List serverIds, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) - .set(ServerFields.EMAIL_VERIFIED, true) - .set(ServerFields.UPDATED_AT, updatedAt); - return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); - } - - public boolean markProvisioningCompleted(String serverId) { - Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId) - .and(ServerFields.PROVISIONING_STATUS).in( - ProvisioningStatus.IN_PROGRESS, ProvisioningStatus.PENDING, ProvisioningStatus.FAILED)); - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED) - .unset(ServerFields.PROVISIONING_NOTES) - .set(ServerFields.UPDATED_AT, new Date()); - return updateFirst(query, update).getModifiedCount() > 0; - } - - public boolean markProvisioningFailed(String serverId, String notes) { - String safeNotes = notes != null && notes.length() > 500 ? notes.substring(0, 500) : notes; - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) - .set(ServerFields.PROVISIONING_NOTES, safeNotes) - .set(ServerFields.UPDATED_AT, new Date()); - return updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update) - .getModifiedCount() > 0; - } - - public Optional applyFieldUpdate(String serverId, Update update) { - if (update.getUpdateObject().isEmpty()) { - return findById(serverId); - } - return Optional.ofNullable(findAndModify( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - update, - FindAndModifyOptions.options().returnNew(true) - )); - } - - public long bulkUpdatePlan(List serverIds, ServerPlan plan, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PLAN, plan) - .set(ServerFields.UPDATED_AT, updatedAt); - return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); - } - - public void updateCustomDomain(String serverId, String customDomain, String status, - String cloudflareHostnameId, String error) { - CustomDomainStatus domainStatus = switch (status) { - case "active" -> CustomDomainStatus.ACTIVE; - case "error" -> CustomDomainStatus.ERROR; - case "verifying" -> CustomDomainStatus.VERIFYING; - default -> CustomDomainStatus.PENDING; - }; - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.CUSTOM_DOMAIN_OVERRIDE, customDomain) - .set(ServerFields.CUSTOM_DOMAIN_STATUS, domainStatus.name()) - .set(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, cloudflareHostnameId) - .set(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED, new Date()) - .set(ServerFields.CUSTOM_DOMAIN_ERROR, error) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void clearCustomDomain(String serverId) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .unset(ServerFields.CUSTOM_DOMAIN_OVERRIDE) - .unset(ServerFields.CUSTOM_DOMAIN_STATUS) - .unset(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID) - .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) - .unset(ServerFields.CUSTOM_DOMAIN_ERROR) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updateStaffPermissionsTimestamp(String serverId, Date timestamp) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.STAFF_PERMISSIONS_UPDATED_AT, timestamp) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updatePunishmentTypesTimestamp(String serverId, Date timestamp) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.PUNISHMENT_TYPES_UPDATED_AT, timestamp) - .set(ServerFields.UPDATED_AT, new Date()) - ); - } - - public void updateLastActivity(String serverId, Date lastActivityAt, long onlinePlayerCount) { - updateFirst( - Query.query(Criteria.where(ServerFields.ID).is(serverId)), - new Update() - .set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) - .set(ServerFields.ONLINE_PLAYER_COUNT, onlinePlayerCount) - ); - } - - public void updateUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { - Update update = new Update() - .set(ServerFields.USER_COUNT, userCount) - .set(ServerFields.TICKET_COUNT, ticketCount) - .set(ServerFields.LAST_STATS_UPDATED_AT, updatedAt); - updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); - } - - public void resetAfterDatabaseDrop(String serverId, Date updatedAt) { - Update update = new Update() - .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.PENDING) - .set(ServerFields.PROVISIONING_NOTES, RESET_MESSAGE) - .unset(ServerFields.LAST_ACTIVITY_AT) - .unset(ServerFields.CUSTOM_DOMAIN_STATUS) - .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) - .unset(ServerFields.CUSTOM_DOMAIN_ERROR) - .set(ServerFields.UPDATED_AT, updatedAt); - updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); - } - - public List aggregateHistoricalMetric(String metric, Date startDate) { - ProjectionOperation projectDateStage = Aggregation.project() - .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE); - - if (METRIC_USERS.equals(metric) || METRIC_TICKETS.equals(metric)) { - String sumField = METRIC_USERS.equals(metric) ? ServerFields.USER_COUNT : ServerFields.TICKET_COUNT; - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), - projectDateStage.and(sumField).as(ALIAS_VALUE_SOURCE), - Aggregation.group(ALIAS_DATE).sum(ALIAS_VALUE_SOURCE).as(ALIAS_VALUE), - Aggregation.sort(Sort.Direction.ASC, "_id"), - Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, DateValueResult.class).getMappedResults(); - } - - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), - projectDateStage, - Aggregation.group(ALIAS_DATE).count().as(ALIAS_VALUE), - Aggregation.sort(Sort.Direction.ASC, "_id"), - Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) - ); - return aggregate(aggregation, DateValueResult.class).getMappedResults(); - } - - public DashboardStats aggregateDashboardStats(Date startDate, Date previousStartDate) { - Document facet = new Document() - .append("total", List.of(new Document("$count", "n"))) - .append("active", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) - .append(ServerFields.EMAIL_VERIFIED, true)), - new Document("$count", "n") - )) - .append("withUsers", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) - .append(ServerFields.USER_COUNT, new Document("$gt", 0))), - new Document("$count", "n") - )) - .append("currentPeriod", List.of( - new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", startDate))), - new Document("$count", "n") - )) - .append("previousPeriod", List.of( - new Document("$match", new Document(ServerFields.CREATED_AT, - new Document("$gte", previousStartDate).append("$lt", startDate))), - new Document("$count", "n") - )) - .append("usage", List.of( - new Document("$group", new Document("_id", null) - .append("totalUsers", new Document("$sum", "$" + ServerFields.USER_COUNT)) - .append("totalTickets", new Document("$sum", "$" + ServerFields.TICKET_COUNT))) - )); - - List pipeline = List.of(new Document("$facet", facet)); - List results = globalTemplate().getCollection(collectionName()) - .aggregate(pipeline) - .into(new ArrayList<>()); - - if (results.isEmpty()) { - return new DashboardStats(0, 0, 0, 0, 0, 0, 0); - } - - Document doc = results.get(0); - long total = extractFacetCount(doc, "total"); - long active = extractFacetCount(doc, "active"); - long withUsers = extractFacetCount(doc, "withUsers"); - long currentPeriod = extractFacetCount(doc, "currentPeriod"); - long previousPeriod = extractFacetCount(doc, "previousPeriod"); - - long totalUsers = 0; - long totalTickets = 0; - List usageList = doc.getList("usage", Document.class, List.of()); - if (!usageList.isEmpty() && usageList.getFirst() instanceof Document usageDoc) { - totalUsers = extractLong(usageDoc, "totalUsers"); - totalTickets = extractLong(usageDoc, "totalTickets"); - } - - return new DashboardStats(total, active, withUsers, currentPeriod, previousPeriod, totalUsers, totalTickets); - } - - public MonitoringServerStats aggregateMonitoringServerStats(Date fiveMinutesAgo, Date oneWeekAgo) { - Document facet = new Document() - .append("total", List.of(new Document("$count", "n"))) - .append("active", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) - .append(ServerFields.EMAIL_VERIFIED, true)), - new Document("$count", "n") - )) - .append("concurrent", List.of( - new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), - new Document("$count", "n") - )) - .append("concurrentPlayers", List.of( - new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), - new Document("$group", new Document("_id", null) - .append("sum", new Document("$sum", "$" + ServerFields.ONLINE_PLAYER_COUNT))) - )) - .append("pending", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, - new Document("$in", List.of(ProvisioningStatus.PENDING.name(), ProvisioningStatus.IN_PROGRESS.name())))), - new Document("$count", "n") - )) - .append("failed", List.of( - new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED.name())), - new Document("$count", "n") - )) - .append("recentRegistrations", List.of( - new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", oneWeekAgo))), - new Document("$count", "n") - )); - - List pipeline = List.of(new Document("$facet", facet)); - List results = globalTemplate().getCollection(collectionName()) - .aggregate(pipeline) - .into(new ArrayList<>()); - - if (results.isEmpty()) { - return new MonitoringServerStats(0, 0, 0, 0, 0, 0, 0); - } - - Document doc = results.get(0); - long concurrentPlayers = 0; - List cpList = doc.getList("concurrentPlayers", Document.class, List.of()); - if (!cpList.isEmpty() && cpList.getFirst() instanceof Document cpDoc) { - concurrentPlayers = extractLong(cpDoc, "sum"); - } - - return new MonitoringServerStats( - extractFacetCount(doc, "total"), - extractFacetCount(doc, "active"), - extractFacetCount(doc, "concurrent"), - concurrentPlayers, - extractFacetCount(doc, "pending"), - extractFacetCount(doc, "failed"), - extractFacetCount(doc, "recentRegistrations") - ); - } - - public record MonitoringServerStats(long total, long active, long concurrent, long concurrentPlayers, - long pending, long failed, long recentRegistrations) {} - - public record DashboardStats(long totalServers, long activeServers, long serversWithData, - long currentPeriodServers, long previousPeriodServers, - long totalUsers, long totalTickets) {} - - public record AIUsageSnapshot(long aiRequestsCurrentPeriod, long maxAiOverageRequests) {} - - public record UsageTotals(long totalUsers, long totalTickets) {} - - public record NameValueResult(String name, int value) {} - - public record DateServersResult(String date, int servers) {} - - public record DateValueResult(String date, long value) {} -} +package gg.modl.backend.database.mongo.repository; + +import static gg.modl.backend.database.mongo.MongoAggregationResults.extractFacetCount; +import static gg.modl.backend.database.mongo.MongoAggregationResults.extractLong; + +import com.mongodb.client.result.UpdateResult; +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.server.data.CustomDomainStatus; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationExpression; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +import org.springframework.data.mongodb.core.aggregation.DateOperators; +import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; +import org.springframework.data.mongodb.core.aggregation.SetOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerMongoRepository extends AbstractGlobalMongoRepository { + private static final String FILTER_ALL = "all"; + private static final String FILTER_ACTIVE = "active"; + private static final String FILTER_PENDING = "pending"; + private static final String FILTER_FAILED = "failed"; + private static final String FILTER_UNVERIFIED = "unverified"; + private static final String DOMAIN_STATUS_ACTIVE = "ACTIVE"; + + private static final String ALIAS_TOTAL = "total"; + private static final String ALIAS_TOTAL_USERS = "totalUsers"; + private static final String ALIAS_TOTAL_TICKETS = "totalTickets"; + private static final String ALIAS_VALUE = "value"; + private static final String ALIAS_NAME = "name"; + private static final String ALIAS_DATE = "date"; + private static final String ALIAS_SERVERS = "servers"; + private static final String ALIAS_VALUE_SOURCE = "valueSource"; + + private static final String METRIC_USERS = "users"; + private static final String METRIC_TICKETS = "tickets"; + + private static final String ORDER_ASC = "asc"; + private static final String INVALID_PLAN_SENTINEL = "__invalid_plan__"; + private static final String RESET_MESSAGE = "Database reset - awaiting reprovisioning"; + + private static final Set ADMIN_SORT_FIELDS = Set.of( + ServerFields.SERVER_NAME, + ServerFields.CUSTOM_DOMAIN, + ServerFields.ADMIN_EMAIL, + ServerFields.PLAN, + ServerFields.CREATED_AT, + ServerFields.UPDATED_AT, + ServerFields.USER_COUNT, + ServerFields.PROVISIONING_STATUS, + ServerFields.LAST_ACTIVITY_AT + ); + + public ServerMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public long countAll() { + return count(new Query()); + } + + public Optional findByCustomDomain(String customDomain) { + return findOne(Query.query(Criteria.where(ServerFields.CUSTOM_DOMAIN).is(customDomain))); + } + + public Optional findByActiveCustomDomainOverride(String domain) { + Criteria criteria = new Criteria().andOperator( + Criteria.where(ServerFields.CUSTOM_DOMAIN_OVERRIDE).is(domain), + Criteria.where(ServerFields.CUSTOM_DOMAIN_STATUS).is(DOMAIN_STATUS_ACTIVE) + ); + return findOne(new Query(criteria)); + } + + public Optional findMatchingIdentity(String email, String serverName, String subdomain) { + Criteria criteria = new Criteria().orOperator( + Criteria.where(ServerFields.ADMIN_EMAIL).is(email), + Criteria.where(ServerFields.SERVER_NAME).is(serverName), + Criteria.where(ServerFields.CUSTOM_DOMAIN).is(subdomain) + ); + return findOne(new Query(criteria)); + } + + public Optional findByDatabaseName(String databaseName) { + return findOne(Query.query(Criteria.where(ServerFields.DATABASE_NAME).is(databaseName))); + } + + public Optional findByApiKey(String apiKey) { + return findOne(Query.query(Criteria.where(ServerFields.API_KEY).is(apiKey))); + } + + public boolean existsByAdminEmailExcludingId(String adminEmail, String excludedServerId) { + Criteria criteria = Criteria.where(ServerFields.ADMIN_EMAIL) + .regex("^" + Pattern.quote(adminEmail) + "$", "i") + .and(ServerFields.ID).ne(excludedServerId); + return exists(Query.query(criteria)); + } + + public Optional findByEmailVerificationToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token))); + } + + public Optional verifyEmailTokenAtomically(String token) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).is(token), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), + noCleanupClaimCriteria() + )); + + Update update = new Update() + .set(ServerFields.EMAIL_VERIFIED, true) + .unset(ServerFields.EMAIL_VERIFICATION_TOKEN) + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) + .set(ServerFields.UPDATED_AT, new Date()); + + return Optional.ofNullable(findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public Optional findByProvisioningSignInToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token))); + } + + public Optional consumeProvisioningSignInToken(String token, Date now) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token), + Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT).gt(now), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED) + )); + Update update = new Update() + .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN) + .unset(ServerFields.PROVISIONING_SIGN_IN_TOKEN_EXPIRES_AT) + .set(ServerFields.UPDATED_AT, now); + return Optional.ofNullable(findAndModify(query, update, FindAndModifyOptions.options().returnNew(true))); + } + + public Optional findByCliSetupToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.CLI_SETUP_TOKEN).is(token))); + } + + public Optional findByStripeCustomerId(String customerId) { + if (customerId == null) { + return Optional.empty(); + } + return findOne(Query.query(Criteria.where(ServerFields.STRIPE_CUSTOMER_ID).is(customerId))); + } + + public Optional findByStripeSubscriptionId(String subscriptionId) { + return findOne(Query.query(Criteria.where(ServerFields.STRIPE_SUBSCRIPTION_ID).is(subscriptionId))); + } + + public long countCompletedAndVerified() { + return count(Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true) + ))); + } + + public long countByProvisioningStatus(ProvisioningStatus status) { + return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).is(status))); + } + + public long countByProvisioningStatuses(ProvisioningStatus... statuses) { + return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).in((Object[]) statuses))); + } + + public long countActiveSince(Date activityCutoff) { + return count(Query.query(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff))); + } + + public long countCompletedWithUsers() { + return count(Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), + Criteria.where(ServerFields.USER_COUNT).gt(0) + ))); + } + + public long countCreatedSince(Date startDate) { + return count(Query.query(Criteria.where(ServerFields.CREATED_AT).gte(startDate))); + } + + public long countCreatedBetween(Date startDate, Date endDate) { + return count(Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.CREATED_AT).gte(startDate), + Criteria.where(ServerFields.CREATED_AT).lt(endDate) + ))); + } + + public long sumOnlinePlayersSince(Date activityCutoff) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff)), + Aggregation.group().sum(ServerFields.ONLINE_PLAYER_COUNT).as(ALIAS_TOTAL) + ); + Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); + return extractLong(result, ALIAS_TOTAL); + } + + public UsageTotals getUsageTotals() { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group() + .sum(ServerFields.USER_COUNT).as(ALIAS_TOTAL_USERS) + .sum(ServerFields.TICKET_COUNT).as(ALIAS_TOTAL_TICKETS) + ); + Document result = aggregate(aggregation, Document.class).getUniqueMappedResult(); + return new UsageTotals( + extractLong(result, ALIAS_TOTAL_USERS), + extractLong(result, ALIAS_TOTAL_TICKETS) + ); + } + + public List aggregatePlanCounts() { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group(ServerFields.PLAN).count().as(ALIAS_VALUE), + Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, NameValueResult.class).getMappedResults(); + } + + public List aggregateProvisioningStatusCounts() { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group(ServerFields.PROVISIONING_STATUS).count().as(ALIAS_VALUE), + Aggregation.project().and("_id").as(ALIAS_NAME).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, NameValueResult.class).getMappedResults(); + } + + public List findRegistrationTrend(Date startDate) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), + Aggregation.project() + .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE), + Aggregation.group(ALIAS_DATE).count().as(ALIAS_SERVERS), + Aggregation.sort(Sort.Direction.ASC, "_id"), + Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_SERVERS).as(ALIAS_SERVERS) + ); + return aggregate(aggregation, DateServersResult.class).getMappedResults(); + } + + public List findTopCompletedVerifiedByUserCount(int limit) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), + Criteria.where(ServerFields.USER_COUNT).gt(0) + )); + query.with(Sort.by(Sort.Direction.DESC, ServerFields.USER_COUNT)); + query.limit(limit); + return find(query); + } + + public List findAdminServers(String search, String plan, String status, String sortField, String sortOrder, int skip, int limit) { + Query query = buildAdminServerFilterQuery(search, plan, status); + query.with(Sort.by(resolveSortDirection(sortOrder), resolveAdminSortField(sortField))); + query.skip(skip).limit(limit); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.PLAN) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.PROVISIONING_STATUS) + .include(ServerFields.CREATED_AT) + .include(ServerFields.UPDATED_AT) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_ACTIVITY_AT); + return find(query); + } + + private Query buildAdminServerFilterQuery(String search, String plan, String status) { + Query query = new Query(); + List criteriaList = new ArrayList<>(); + + if (search != null && !search.trim().isEmpty()) { + String escapedSearch = Pattern.quote(search.trim()); + criteriaList.add(new Criteria().orOperator( + Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), + Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), + Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") + )); + } + + if (plan != null && !FILTER_ALL.equals(plan)) { + try { + criteriaList.add(Criteria.where(ServerFields.PLAN).is(ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)))); + } catch (IllegalArgumentException ignored) { + criteriaList.add(Criteria.where(ServerFields.PLAN).is(INVALID_PLAN_SENTINEL)); + } + } + + if (status != null && !FILTER_ALL.equals(status)) { + switch (status) { + case FILTER_ACTIVE -> { + criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED)); + criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(true)); + } + case FILTER_PENDING -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS) + .in(ProvisioningStatus.PENDING, ProvisioningStatus.IN_PROGRESS)); + case FILTER_FAILED -> criteriaList.add(Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.FAILED)); + case FILTER_UNVERIFIED -> criteriaList.add(Criteria.where(ServerFields.EMAIL_VERIFIED).is(false)); + default -> { + } + } + } + + if (!criteriaList.isEmpty()) { + query.addCriteria(new Criteria().andOperator(criteriaList.toArray(new Criteria[0]))); + } + return query; + } + + private Sort.Direction resolveSortDirection(String sortOrder) { + return ORDER_ASC.equalsIgnoreCase(sortOrder) ? Sort.Direction.ASC : Sort.Direction.DESC; + } + + private String resolveAdminSortField(String sortField) { + return ADMIN_SORT_FIELDS.contains(sortField) ? sortField : ServerFields.CREATED_AT; + } + + public long countAdminServers(String search, String plan, String status) { + return count(buildAdminServerFilterQuery(search, plan, status)); + } + + public List findUsageRefreshCandidates(Date staleCutoff, int limit) { + Criteria staleCriteria = new Criteria().orOperator( + Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).exists(false), + Criteria.where(ServerFields.LAST_STATS_UPDATED_AT).lt(staleCutoff) + ); + + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.DATABASE_NAME).ne(null), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.COMPLETED), + Criteria.where(ServerFields.EMAIL_VERIFIED).is(true), + staleCriteria + )); + query.with(Sort.by(Sort.Direction.ASC, ServerFields.LAST_STATS_UPDATED_AT)); + query.limit(limit); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.DATABASE_NAME) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.PLAN) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_STATS_UPDATED_AT) + .include(ServerFields.LAST_ACTIVITY_AT) + .include(ServerFields.UPDATED_AT); + return find(query); + } + + public List findExpiredRegistrationCleanupCandidates(Date cutoff, int limit) { + return findExpiredRegistrationCleanupCandidates(cutoff, new Date(0), limit); + } + + public List findExpiredRegistrationCleanupCandidates(Date cutoff, Date claimCutoff, int limit) { + Query query = Query.query(expiredRegistrationCriteria(cutoff, claimCutoff)); + query.with(Sort.by(Sort.Direction.ASC, ServerFields.CREATED_AT)); + query.limit(limit); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.DATABASE_NAME) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.EMAIL_VERIFICATION_TOKEN) + .include(ServerFields.PROVISIONING_STATUS) + .include(ServerFields.API_KEY) + .include(ServerFields.ONLINE_PLAYER_COUNT) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_ACTIVITY_AT) + .include(ServerFields.CREATED_AT) + .include(ServerFields.UPDATED_AT) + .include(ServerFields.CLEANUP_CLAIM_ID) + .include(ServerFields.CLEANUP_CLAIMED_AT); + return find(query); + } + + public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Instant claimedAt) { + return claimExpiredRegistrationForCleanup(serverId, cutoff, new Date(0), claimedAt); + } + + public Optional claimExpiredRegistrationForCleanup(String serverId, Date cutoff, Date claimCutoff, Instant claimedAt) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + expiredRegistrationCriteria(cutoff, claimCutoff) + )); + Update update = new Update() + .set(ServerFields.CLEANUP_CLAIM_ID, UUID.randomUUID().toString()) + .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(claimedAt)) + .set(ServerFields.UPDATED_AT, Date.from(claimedAt)); + + return Optional.ofNullable(findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public boolean deleteClaimedExpiredRegistration(String serverId, String cleanupClaimId, Date cutoff) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), + explicitExpiredRegistrationCriteria(cutoff) + )); + return remove(query).getDeletedCount() > 0; + } + + public Optional confirmRegistrationCleanupClaim(String serverId, String cleanupClaimId, Date cutoff, Instant confirmedAt) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId), + explicitExpiredRegistrationCriteria(cutoff) + )); + Update update = new Update() + .set(ServerFields.CLEANUP_CLAIMED_AT, Date.from(confirmedAt)) + .set(ServerFields.UPDATED_AT, Date.from(confirmedAt)); + + return Optional.ofNullable(findAndModify( + query, + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public boolean releaseRegistrationCleanupClaim(String serverId, String cleanupClaimId) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(cleanupClaimId) + )); + Update update = new Update() + .unset(ServerFields.CLEANUP_CLAIM_ID) + .unset(ServerFields.CLEANUP_CLAIMED_AT) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(query, update).getModifiedCount() > 0; + } + + public List findUsageTargetsByIds(List serverIds) { + Query query = Query.query(Criteria.where(ServerFields.ID).in(serverIds)); + query.fields() + .include(ServerFields.SERVER_NAME) + .include(ServerFields.CUSTOM_DOMAIN) + .include(ServerFields.DATABASE_NAME) + .include(ServerFields.ADMIN_EMAIL) + .include(ServerFields.EMAIL_VERIFIED) + .include(ServerFields.PLAN) + .include(ServerFields.USER_COUNT) + .include(ServerFields.TICKET_COUNT) + .include(ServerFields.LAST_STATS_UPDATED_AT) + .include(ServerFields.LAST_ACTIVITY_AT) + .include(ServerFields.UPDATED_AT); + return find(query); + } + + private Criteria expiredRegistrationCriteria(Date cutoff, Date claimCutoff) { + return new Criteria().andOperator( + explicitExpiredRegistrationCriteria(cutoff), + cleanupClaimEligibleCriteria(claimCutoff) + ); + } + + private Criteria explicitExpiredRegistrationCriteria(Date cutoff) { + return new Criteria().andOperator( + Criteria.where(ServerFields.EMAIL_VERIFIED).is(false), + Criteria.where(ServerFields.PROVISIONING_STATUS).is(ProvisioningStatus.PENDING), + Criteria.where(ServerFields.EMAIL_VERIFICATION_TOKEN).exists(true).nin(null, ""), + Criteria.where(ServerFields.CREATED_AT).exists(true).lt(cutoff), + Criteria.where(ServerFields.DATABASE_NAME).regex("^server_.+"), + notPresentOrBlank(ServerFields.API_KEY), + notPresent(ServerFields.LAST_ACTIVITY_AT), + notPositive(ServerFields.USER_COUNT), + notPositive(ServerFields.TICKET_COUNT), + notPositive(ServerFields.ONLINE_PLAYER_COUNT) + ); + } + + private Criteria cleanupClaimEligibleCriteria(Date claimCutoff) { + return new Criteria().orOperator( + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null), + Criteria.where(ServerFields.CLEANUP_CLAIMED_AT).lt(claimCutoff) + ); + } + + private Criteria noCleanupClaimCriteria() { + return new Criteria().orOperator( + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null) + ); + } + + private Criteria notPresent(String field) { + return new Criteria().orOperator( + Criteria.where(field).exists(false), + Criteria.where(field).is(null) + ); + } + + private Criteria notPresentOrBlank(String field) { + return new Criteria().orOperator( + Criteria.where(field).exists(false), + Criteria.where(field).is(null), + Criteria.where(field).is("") + ); + } + + private Criteria notPositive(String field) { + return new Criteria().orOperator( + Criteria.where(field).exists(false), + Criteria.where(field).is(null), + Criteria.where(field).lte(0) + ); + } + + public List findProvisioningCandidatesByIds(List serverIds) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).in(serverIds), + Criteria.where(ServerFields.DATABASE_NAME).exists(true).ne(null) + )); + return find(query); + } + + public List findCancelledWithPeriodEnd() { + Criteria criteria = new Criteria().andOperator( + Criteria.where(ServerFields.SUBSCRIPTION_STATUS).is(SubscriptionStatus.CANCELED), + Criteria.where(ServerFields.CURRENT_PERIOD_END).exists(true).ne(null) + ); + return find(new Query(criteria)); + } + + public void incrementAiRequests(String serverId, long additionalRequests) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().inc(ServerFields.AI_REQUESTS_CURRENT_PERIOD, additionalRequests) + ); + } + + public void incrementStorageUsed(String serverId, long bytes) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes) + ); + } + + public boolean tryIncrementStorageUsedWithinLimit(String serverId, long bytes, long maxBytes) { + long maxCurrentBytes = maxBytes - bytes; + if (bytes < 0 || maxCurrentBytes < 0) { + return false; + } + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + new Criteria().orOperator( + Criteria.where(ServerFields.STORAGE_USED_BYTES).lte(maxCurrentBytes), + Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false), + Criteria.where(ServerFields.STORAGE_USED_BYTES).is(null) + ) + )); + UpdateResult result = updateFirst(query, new Update().inc(ServerFields.STORAGE_USED_BYTES, bytes)); + return result.getMatchedCount() == 1; + } + + public void decrementStorageUsed(String serverId, long bytes) { + AggregationUpdate update = AggregationUpdate.update().set( + SetOperation.set(ServerFields.STORAGE_USED_BYTES).toValueOf(flooredStorageAfterDecrement(bytes)) + ); + globalTemplate().updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + collectionName() + ); + } + + private AggregationExpression flooredStorageAfterDecrement(long bytes) { + return context -> new Document("$max", List.of(0L, new Document("$subtract", + List.of("$" + ServerFields.STORAGE_USED_BYTES, bytes)))); + } + + public void setStorageUsed(String serverId, long bytes) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().set(ServerFields.STORAGE_USED_BYTES, bytes) + ); + } + + public boolean setStorageUsedIfBelow(String serverId, long bytes) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(ServerFields.ID).is(serverId), + new Criteria().orOperator( + Criteria.where(ServerFields.STORAGE_USED_BYTES).lt(bytes), + Criteria.where(ServerFields.STORAGE_USED_BYTES).exists(false) + ) + )); + UpdateResult result = updateFirst(query, new Update().set(ServerFields.STORAGE_USED_BYTES, bytes)); + return result.getModifiedCount() == 1; + } + + public Optional findAIUsageSnapshotById(String serverId) { + Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId)); + query.fields() + .include(ServerFields.AI_REQUESTS_CURRENT_PERIOD) + .include(ServerFields.MAX_AI_OVERAGE_REQUESTS); + + Document document = globalTemplate().findOne(query, Document.class, collectionName()); + if (document == null) { + return Optional.empty(); + } + + return Optional.of(new AIUsageSnapshot( + extractLong(document, ServerFields.AI_REQUESTS_CURRENT_PERIOD), + extractLong(document, ServerFields.MAX_AI_OVERAGE_REQUESTS) + )); + } + + public void resetUsageCounters(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) + ); + } + + public void resetUsageAndStatsCounters(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.STORAGE_USED_BYTES, 0L) + .set(ServerFields.USER_COUNT, 0L) + .set(ServerFields.TICKET_COUNT, 0L) + .set(ServerFields.ONLINE_PLAYER_COUNT, 0L) + .set(ServerFields.AI_REQUESTS_CURRENT_PERIOD, 0L) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public List findBetaTesters(String search, int skip, int limit) { + Query query = buildBetaTesterQuery(search); + query.with(Sort.by(Sort.Direction.DESC, ServerFields.BETA_TESTER_CREATED_AT)); + query.skip(skip).limit(limit); + return find(query); + } + + public long countBetaTesters(String search) { + return count(buildBetaTesterQuery(search)); + } + + public List findAllBetaTesters() { + return find(Query.query(Criteria.where(ServerFields.BETA_TESTER).is(true))); + } + + private Query buildBetaTesterQuery(String search) { + Criteria betaCriteria = Criteria.where(ServerFields.BETA_TESTER_CREATED_AT).exists(true); + if (search == null || search.trim().isEmpty()) { + return new Query(betaCriteria); + } + String escapedSearch = Pattern.quote(search.trim()); + return new Query(new Criteria().andOperator( + betaCriteria, + new Criteria().orOperator( + Criteria.where(ServerFields.SERVER_NAME).regex(escapedSearch, "i"), + Criteria.where(ServerFields.CUSTOM_DOMAIN).regex(escapedSearch, "i"), + Criteria.where(ServerFields.ADMIN_EMAIL).regex(escapedSearch, "i") + ) + )); + } + + public Optional updateBetaState(String serverId, ServerPlan plan, SubscriptionStatus subscriptionStatus, boolean betaTester) { + Update update = new Update() + .set(ServerFields.PLAN, plan) + .set(ServerFields.SUBSCRIPTION_STATUS, subscriptionStatus) + .set(ServerFields.BETA_TESTER, betaTester) + .set(ServerFields.UPDATED_AT, new Date()); + return Optional.ofNullable(findAndModify( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public void updateAdminEmail(String serverId, String adminEmail) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.ADMIN_EMAIL, adminEmail) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updateApiKey(String serverId, String apiKey) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().set(ServerFields.API_KEY, apiKey) + ); + } + + public void clearApiKey(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update().unset(ServerFields.API_KEY) + ); + } + + public Optional updateAllowedFields(String serverId, Map updateData) { + Update update = new Update(); + boolean hasChanges = false; + + for (Map.Entry entry : updateData.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (value == null) { + continue; + } + + switch (key) { + case ServerFields.ADMIN_EMAIL -> { + update.set(ServerFields.ADMIN_EMAIL, value); + hasChanges = true; + } + case ServerFields.EMAIL_VERIFIED -> { + update.set(ServerFields.EMAIL_VERIFIED, value); + hasChanges = true; + } + case ServerFields.PROVISIONING_STATUS -> { + update.set(ServerFields.PROVISIONING_STATUS, normalizeProvisioningStatus(value)); + hasChanges = true; + } + case ServerFields.PROVISIONING_NOTES -> { + update.set(ServerFields.PROVISIONING_NOTES, value); + hasChanges = true; + } + case ServerFields.PLAN -> { + update.set(ServerFields.PLAN, normalizePlan(value)); + hasChanges = true; + } + case ServerFields.SUBSCRIPTION_STATUS -> { + update.set(ServerFields.SUBSCRIPTION_STATUS, normalizeSubscriptionStatus(value)); + hasChanges = true; + } + case ServerFields.LAST_ACTIVITY_AT -> { + update.set(ServerFields.LAST_ACTIVITY_AT, normalizeDate(value)); + hasChanges = true; + } + case ServerFields.UPDATED_AT -> { + update.set(ServerFields.UPDATED_AT, normalizeDate(value)); + hasChanges = true; + } + default -> { + } + } + } + + if (!hasChanges) { + return findById(serverId); + } + + Server updated = findAndModify( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + FindAndModifyOptions.options().returnNew(true) + ); + return Optional.ofNullable(updated); + } + + private ServerPlan normalizePlan(Object value) { + if (value instanceof ServerPlan plan) { + return plan; + } + return ServerPlan.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); + } + + private ProvisioningStatus normalizeProvisioningStatus(Object value) { + if (value instanceof ProvisioningStatus provisioningStatus) { + return provisioningStatus; + } + return ProvisioningStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); + } + + private SubscriptionStatus normalizeSubscriptionStatus(Object value) { + if (value instanceof SubscriptionStatus subscriptionStatus) { + return subscriptionStatus; + } + return SubscriptionStatus.valueOf(String.valueOf(value).trim().toUpperCase(Locale.ROOT)); + } + + private Date normalizeDate(Object value) { + if (value instanceof Date d) { + return d; + } + if (value instanceof Instant i) { + return Date.from(i); + } + if (value instanceof Number n) { + return new Date(n.longValue()); + } + if (value instanceof String s) { + return Date.from(Instant.parse(s.trim())); + } + throw new IllegalArgumentException("Unsupported value type for date field: " + + (value == null ? "null" : value.getClass())); + } + + public boolean deleteByServerId(String serverId) { + return remove(Query.query(Criteria.where(ServerFields.ID).is(serverId))).getDeletedCount() > 0; + } + + public long deleteByServerIds(List serverIds) { + return remove(Query.query(Criteria.where(ServerFields.ID).in(serverIds))).getDeletedCount(); + } + + public long bulkSuspend(List serverIds, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) + .set(ServerFields.UPDATED_AT, updatedAt); + return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); + } + + public long bulkActivate(List serverIds, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.IN_PROGRESS) + .set(ServerFields.EMAIL_VERIFIED, true) + .set(ServerFields.UPDATED_AT, updatedAt); + return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); + } + + public boolean markProvisioningCompleted(String serverId) { + Query query = Query.query(Criteria.where(ServerFields.ID).is(serverId) + .and(ServerFields.PROVISIONING_STATUS).in( + ProvisioningStatus.IN_PROGRESS, ProvisioningStatus.PENDING, ProvisioningStatus.FAILED)); + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED) + .unset(ServerFields.PROVISIONING_NOTES) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(query, update).getModifiedCount() > 0; + } + + public boolean markProvisioningFailed(String serverId, String notes) { + String safeNotes = notes != null && notes.length() > 500 ? notes.substring(0, 500) : notes; + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) + .set(ServerFields.PROVISIONING_NOTES, safeNotes) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update) + .getModifiedCount() > 0; + } + + public Optional applyFieldUpdate(String serverId, Update update) { + if (update.getUpdateObject().isEmpty()) { + return findById(serverId); + } + return Optional.ofNullable(findAndModify( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + update, + FindAndModifyOptions.options().returnNew(true) + )); + } + + public long bulkUpdatePlan(List serverIds, ServerPlan plan, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PLAN, plan) + .set(ServerFields.UPDATED_AT, updatedAt); + return updateMulti(Query.query(Criteria.where(ServerFields.ID).in(serverIds)), update).getModifiedCount(); + } + + public void updateCustomDomain(String serverId, String customDomain, String status, + String cloudflareHostnameId, String error) { + CustomDomainStatus domainStatus = switch (status) { + case "active" -> CustomDomainStatus.ACTIVE; + case "error" -> CustomDomainStatus.ERROR; + case "verifying" -> CustomDomainStatus.VERIFYING; + default -> CustomDomainStatus.PENDING; + }; + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.CUSTOM_DOMAIN_OVERRIDE, customDomain) + .set(ServerFields.CUSTOM_DOMAIN_STATUS, domainStatus.name()) + .set(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, cloudflareHostnameId) + .set(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED, new Date()) + .set(ServerFields.CUSTOM_DOMAIN_ERROR, error) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void clearCustomDomain(String serverId) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .unset(ServerFields.CUSTOM_DOMAIN_OVERRIDE) + .unset(ServerFields.CUSTOM_DOMAIN_STATUS) + .unset(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID) + .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) + .unset(ServerFields.CUSTOM_DOMAIN_ERROR) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updateStaffPermissionsTimestamp(String serverId, Date timestamp) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.STAFF_PERMISSIONS_UPDATED_AT, timestamp) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updatePunishmentTypesTimestamp(String serverId, Date timestamp) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.PUNISHMENT_TYPES_UPDATED_AT, timestamp) + .set(ServerFields.UPDATED_AT, new Date()) + ); + } + + public void updateLastActivity(String serverId, Date lastActivityAt, long onlinePlayerCount) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) + .set(ServerFields.ONLINE_PLAYER_COUNT, onlinePlayerCount) + ); + } + + public void updateUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { + Update update = new Update() + .set(ServerFields.USER_COUNT, userCount) + .set(ServerFields.TICKET_COUNT, ticketCount) + .set(ServerFields.LAST_STATS_UPDATED_AT, updatedAt); + updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); + } + + public void resetAfterDatabaseDrop(String serverId, Date updatedAt) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.PENDING) + .set(ServerFields.PROVISIONING_NOTES, RESET_MESSAGE) + .unset(ServerFields.LAST_ACTIVITY_AT) + .unset(ServerFields.CUSTOM_DOMAIN_STATUS) + .unset(ServerFields.CUSTOM_DOMAIN_LAST_CHECKED) + .unset(ServerFields.CUSTOM_DOMAIN_ERROR) + .set(ServerFields.UPDATED_AT, updatedAt); + updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update); + } + + public List aggregateHistoricalMetric(String metric, Date startDate) { + ProjectionOperation projectDateStage = Aggregation.project() + .and(DateOperators.DateToString.dateOf(ServerFields.CREATED_AT).toString("%Y-%m-%d")).as(ALIAS_DATE); + + if (METRIC_USERS.equals(metric) || METRIC_TICKETS.equals(metric)) { + String sumField = METRIC_USERS.equals(metric) ? ServerFields.USER_COUNT : ServerFields.TICKET_COUNT; + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), + projectDateStage.and(sumField).as(ALIAS_VALUE_SOURCE), + Aggregation.group(ALIAS_DATE).sum(ALIAS_VALUE_SOURCE).as(ALIAS_VALUE), + Aggregation.sort(Sort.Direction.ASC, "_id"), + Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, DateValueResult.class).getMappedResults(); + } + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(ServerFields.CREATED_AT).gte(startDate)), + projectDateStage, + Aggregation.group(ALIAS_DATE).count().as(ALIAS_VALUE), + Aggregation.sort(Sort.Direction.ASC, "_id"), + Aggregation.project().and("_id").as(ALIAS_DATE).and(ALIAS_VALUE).as(ALIAS_VALUE) + ); + return aggregate(aggregation, DateValueResult.class).getMappedResults(); + } + + public DashboardStats aggregateDashboardStats(Date startDate, Date previousStartDate) { + Document facet = new Document() + .append("total", List.of(new Document("$count", "n"))) + .append("active", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) + .append(ServerFields.EMAIL_VERIFIED, true)), + new Document("$count", "n") + )) + .append("withUsers", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) + .append(ServerFields.USER_COUNT, new Document("$gt", 0))), + new Document("$count", "n") + )) + .append("currentPeriod", List.of( + new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", startDate))), + new Document("$count", "n") + )) + .append("previousPeriod", List.of( + new Document("$match", new Document(ServerFields.CREATED_AT, + new Document("$gte", previousStartDate).append("$lt", startDate))), + new Document("$count", "n") + )) + .append("usage", List.of( + new Document("$group", new Document("_id", null) + .append("totalUsers", new Document("$sum", "$" + ServerFields.USER_COUNT)) + .append("totalTickets", new Document("$sum", "$" + ServerFields.TICKET_COUNT))) + )); + + List pipeline = List.of(new Document("$facet", facet)); + List results = globalTemplate().getCollection(collectionName()) + .aggregate(pipeline) + .into(new ArrayList<>()); + + if (results.isEmpty()) { + return new DashboardStats(0, 0, 0, 0, 0, 0, 0); + } + + Document doc = results.get(0); + long total = extractFacetCount(doc, "total"); + long active = extractFacetCount(doc, "active"); + long withUsers = extractFacetCount(doc, "withUsers"); + long currentPeriod = extractFacetCount(doc, "currentPeriod"); + long previousPeriod = extractFacetCount(doc, "previousPeriod"); + + long totalUsers = 0; + long totalTickets = 0; + List usageList = doc.getList("usage", Document.class, List.of()); + if (!usageList.isEmpty() && usageList.getFirst() instanceof Document usageDoc) { + totalUsers = extractLong(usageDoc, "totalUsers"); + totalTickets = extractLong(usageDoc, "totalTickets"); + } + + return new DashboardStats(total, active, withUsers, currentPeriod, previousPeriod, totalUsers, totalTickets); + } + + public MonitoringServerStats aggregateMonitoringServerStats(Date fiveMinutesAgo, Date oneWeekAgo) { + Document facet = new Document() + .append("total", List.of(new Document("$count", "n"))) + .append("active", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.COMPLETED.name()) + .append(ServerFields.EMAIL_VERIFIED, true)), + new Document("$count", "n") + )) + .append("concurrent", List.of( + new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), + new Document("$count", "n") + )) + .append("concurrentPlayers", List.of( + new Document("$match", new Document(ServerFields.LAST_ACTIVITY_AT, new Document("$gte", fiveMinutesAgo))), + new Document("$group", new Document("_id", null) + .append("sum", new Document("$sum", "$" + ServerFields.ONLINE_PLAYER_COUNT))) + )) + .append("pending", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, + new Document("$in", List.of(ProvisioningStatus.PENDING.name(), ProvisioningStatus.IN_PROGRESS.name())))), + new Document("$count", "n") + )) + .append("failed", List.of( + new Document("$match", new Document(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED.name())), + new Document("$count", "n") + )) + .append("recentRegistrations", List.of( + new Document("$match", new Document(ServerFields.CREATED_AT, new Document("$gte", oneWeekAgo))), + new Document("$count", "n") + )); + + List pipeline = List.of(new Document("$facet", facet)); + List results = globalTemplate().getCollection(collectionName()) + .aggregate(pipeline) + .into(new ArrayList<>()); + + if (results.isEmpty()) { + return new MonitoringServerStats(0, 0, 0, 0, 0, 0, 0); + } + + Document doc = results.get(0); + long concurrentPlayers = 0; + List cpList = doc.getList("concurrentPlayers", Document.class, List.of()); + if (!cpList.isEmpty() && cpList.getFirst() instanceof Document cpDoc) { + concurrentPlayers = extractLong(cpDoc, "sum"); + } + + return new MonitoringServerStats( + extractFacetCount(doc, "total"), + extractFacetCount(doc, "active"), + extractFacetCount(doc, "concurrent"), + concurrentPlayers, + extractFacetCount(doc, "pending"), + extractFacetCount(doc, "failed"), + extractFacetCount(doc, "recentRegistrations") + ); + } + + public record MonitoringServerStats(long total, long active, long concurrent, long concurrentPlayers, + long pending, long failed, long recentRegistrations) {} + + public record DashboardStats(long totalServers, long activeServers, long serversWithData, + long currentPeriodServers, long previousPeriodServers, + long totalUsers, long totalTickets) {} + + public record AIUsageSnapshot(long aiRequestsCurrentPeriod, long maxAiOverageRequests) {} + + public record UsageTotals(long totalUsers, long totalTickets) {} + + public record NameValueResult(String name, int value) {} + + public record DateServersResult(String date, int servers) {} + + public record DateValueResult(String date, long value) {} +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java index b8d5baf..13a6c2c 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SettingsMongoRepository.java @@ -1,96 +1,96 @@ -package gg.modl.backend.database.mongo.repository; - -import com.mongodb.client.result.UpdateResult; -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.SettingsFields; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class SettingsMongoRepository extends AbstractServerMongoRepository { - public SettingsMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Settings.class, CollectionName.SETTINGS, tenantMongoAccess); - } - - public boolean existsByType(Server server, String type) { - return exists(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); - } - - public Optional findByType(Server server, String type) { - return findOne(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); - } - - public void upsertData(Server server, String type, Map data) { - upsertRawData(server, type, data); - } - - private void upsertRawData(Server server, String type, Object data) { - Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); - Update update = new Update() - .set(SettingsFields.TYPE, type) - .set(SettingsFields.DATA, data); - upsert(server, query, update); - } - - public void upsertListData(Server server, String type, Object data) { - upsertRawData(server, type, data); - } - - public void updateDataByType(Server server, String type, Map data) { - Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); - Update update = new Update().set(SettingsFields.DATA, data); - updateFirst(server, query, update); - } - - public void removeByType(Server server, String type) { - remove(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); - } - - public List findLatestByType(Server server, String type, int limit) { - Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)) - .with(Sort.by( - Sort.Order.desc(SettingsFields.VERSION), - Sort.Order.desc(SettingsFields.UPDATED_AT), - Sort.Order.desc(SettingsFields.ID) - )) - .limit(limit); - return find(server, query); - } - - public boolean updateWithVersionCheck(Server server, String settingsId, long expectedVersion, - String type, Map data, long newVersion, Date updatedAt) { - Criteria versionCriteria = buildVersionCriteria(expectedVersion); - Query updateQuery = Query.query(Criteria.where(SettingsFields.ID).is(settingsId) - .andOperator(versionCriteria)); - Update update = new Update() - .set(SettingsFields.TYPE, type) - .set(SettingsFields.DATA, data) - .set(SettingsFields.VERSION, newVersion) - .set(SettingsFields.UPDATED_AT, updatedAt); - UpdateResult result = updateFirst(server, updateQuery, update); - return result.getModifiedCount() > 0; - } - - private Criteria buildVersionCriteria(long expectedVersion) { - if (expectedVersion == 0L) { - return new Criteria().orOperator( - Criteria.where(SettingsFields.VERSION).is(0L), - Criteria.where(SettingsFields.VERSION).exists(false), - Criteria.where(SettingsFields.VERSION).is(null) - ); - } - return Criteria.where(SettingsFields.VERSION).is(expectedVersion); - } -} - +package gg.modl.backend.database.mongo.repository; + +import com.mongodb.client.result.UpdateResult; +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.SettingsFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.Settings; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class SettingsMongoRepository extends AbstractServerMongoRepository { + public SettingsMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Settings.class, CollectionName.SETTINGS, tenantMongoAccess); + } + + public boolean existsByType(Server server, String type) { + return exists(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); + } + + public Optional findByType(Server server, String type) { + return findOne(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); + } + + public void upsertData(Server server, String type, Map data) { + upsertRawData(server, type, data); + } + + private void upsertRawData(Server server, String type, Object data) { + Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); + Update update = new Update() + .set(SettingsFields.TYPE, type) + .set(SettingsFields.DATA, data); + upsert(server, query, update); + } + + public void upsertListData(Server server, String type, Object data) { + upsertRawData(server, type, data); + } + + public void updateDataByType(Server server, String type, Map data) { + Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); + Update update = new Update().set(SettingsFields.DATA, data); + updateFirst(server, query, update); + } + + public void removeByType(Server server, String type) { + remove(server, Query.query(Criteria.where(SettingsFields.TYPE).is(type))); + } + + public List findLatestByType(Server server, String type, int limit) { + Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)) + .with(Sort.by( + Sort.Order.desc(SettingsFields.VERSION), + Sort.Order.desc(SettingsFields.UPDATED_AT), + Sort.Order.desc(SettingsFields.ID) + )) + .limit(limit); + return find(server, query); + } + + public boolean updateWithVersionCheck(Server server, String settingsId, long expectedVersion, + String type, Map data, long newVersion, Date updatedAt) { + Criteria versionCriteria = buildVersionCriteria(expectedVersion); + Query updateQuery = Query.query(Criteria.where(SettingsFields.ID).is(settingsId) + .andOperator(versionCriteria)); + Update update = new Update() + .set(SettingsFields.TYPE, type) + .set(SettingsFields.DATA, data) + .set(SettingsFields.VERSION, newVersion) + .set(SettingsFields.UPDATED_AT, updatedAt); + UpdateResult result = updateFirst(server, updateQuery, update); + return result.getModifiedCount() > 0; + } + + private Criteria buildVersionCriteria(long expectedVersion) { + if (expectedVersion == 0L) { + return new Criteria().orOperator( + Criteria.where(SettingsFields.VERSION).is(0L), + Criteria.where(SettingsFields.VERSION).exists(false), + Criteria.where(SettingsFields.VERSION).is(null) + ); + } + return Criteria.where(SettingsFields.VERSION).is(expectedVersion); + } +} + diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java index 74128e2..21e714c 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StaffMongoRepository.java @@ -1,244 +1,244 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.StaffFields; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.email.EmailAddressUtil; -import java.util.Collection; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Pattern; -import org.bson.Document; -import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationResults; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class StaffMongoRepository extends AbstractServerMongoRepository { - public StaffMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Staff.class, CollectionName.STAFF, tenantMongoAccess); - } - - public long countAll(Server server) { - return count(server, new Query()); - } - - public Optional findByUsername(Server server, String username) { - return findOne(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); - } - - public Optional findByEmailExact(Server server, String email) { - return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); - } - - public boolean existsByUsername(Server server, String username) { - return exists(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); - } - - public boolean existsByEmailOrUsername(Server server, String email, String username) { - Query query = new Query(new Criteria().orOperator( - Criteria.where(StaffFields.EMAIL).is(email), - Criteria.where(StaffFields.USERNAME).is(username) - )); - return exists(server, query); - } - - public boolean existsByEmailExact(Server server, String email) { - return exists(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); - } - - public boolean existsByEmailEqualsIgnoreCase(Server server, String email) { - return exists(server, Query.query(Criteria.where(StaffFields.EMAIL) - .regex("^" + Pattern.quote(email) + "$", "i"))); - } - - public boolean existsByEmailIgnoreCaseOrUsername(Server server, String normalizedEmail, String username) { - Query query = new Query(new Criteria().orOperator( - Criteria.where(StaffFields.EMAIL).is(normalizedEmail), - Criteria.where(StaffFields.USERNAME).is(username) - )); - return exists(server, query); - } - - public boolean existsByEmailIgnoreCaseExcluding(Server server, String email, String currentEmail) { - Staff existing = findByEmailIgnoreCase(server, email).orElse(null); - return existing != null && !existing.getEmail().equalsIgnoreCase(currentEmail); - } - - public Optional findByEmailIgnoreCase(Server server, String email) { - return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL) - .is(EmailAddressUtil.normalize(email)))); - } - - public boolean existsByUsernameExcludingId(Server server, String username, String excludedStaffId) { - Query query = Query.query(Criteria.where(StaffFields.USERNAME).is(username) - .and(StaffFields.ID).ne(excludedStaffId)); - return exists(server, query); - } - - public boolean deleteById(Server server, String staffId) { - return remove(server, Query.query(Criteria.where(StaffFields.ID).is(staffId))).getDeletedCount() > 0; - } - - public List findAssignedMinecraftStaff(Server server) { - Query query = Query.query( - Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") - ); - return find(server, query); - } - - public Optional findByAssignedMinecraftUuidExcludingId(Server server, String minecraftUuid, String excludedStaffId) { - Query query = Query.query( - Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid) - .and(StaffFields.ID).ne(excludedStaffId) - ); - return findOne(server, query); - } - - public boolean updateLastSeenByAssignedMinecraftUuid(Server server, String minecraftUuid) { - Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update().set(StaffFields.LAST_SEEN, new Date()); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public int countByRoleId(Server server, String roleId) { - return (int) count(server, Query.query(Criteria.where(StaffFields.ROLE_ID).is(roleId))); - } - - public Map countByRoleId(Server server) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(StaffFields.ROLE_ID).count().as("count") - ); - AggregationResults results = aggregate(server, aggregation, Document.class); - Map counts = new LinkedHashMap<>(); - for (Document document : results.getMappedResults()) { - String roleId = document.getString("_id"); - if (roleId != null) { - counts.put(roleId, document.getInteger("count", 0)); - } - } - return counts; - } - - public Map findUsernamesByIds(Server server, Set ids) { - if (ids == null || ids.isEmpty()) { - return Map.of(); - } - Query query = Query.query(Criteria.where(StaffFields.ID).in(ids)); - query.fields().include(StaffFields.USERNAME); - Map result = new HashMap<>(); - for (Staff staff : find(server, query)) { - if (staff.getId() != null && staff.getUsername() != null) { - result.put(staff.getId(), staff.getUsername()); - } - } - return result; - } - - public boolean createTwoFactorToken(Server server, String minecraftUuid, String token, String ip, long createdAt) { - Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); - Update update = new Update(); - update.set(StaffFields.TWO_FACTOR_TOKEN, token); - update.set(StaffFields.TWO_FACTOR_TOKEN_IP, ip); - update.set(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT, createdAt); - return updateFirst(server, query, update).getMatchedCount() > 0; - } - - public Optional findByTwoFactorToken(Server server, String token) { - return findOne(server, Query.query(Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token))); - } - - public boolean activateTwoFactorSession(Server server, String staffId, String token, String sessionIp, long sessionExpiresAt) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(StaffFields.ID).is(staffId), - Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token) - )); - Update update = new Update(); - update.unset(StaffFields.TWO_FACTOR_TOKEN); - update.unset(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT); - update.set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, true); - update.set(StaffFields.TWO_FACTOR_SESSION_IP, sessionIp); - update.unset(StaffFields.TWO_FACTOR_TOKEN_IP); - update.set(StaffFields.TWO_FACTOR_SESSION_EXPIRES_AT, sessionExpiresAt); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public boolean deactivateSubscription(Server server, String email, String ticketId) { - Query query = Query.query( - Criteria.where(StaffFields.EMAIL).is(email) - .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) - .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) - ); - Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_ACTIVE, false); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public boolean markSubscriptionRead(Server server, String email, String ticketId, java.util.Date lastReadAt) { - Query query = Query.query( - Criteria.where(StaffFields.EMAIL).is(email) - .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) - .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) - ); - Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_LAST_READ_AT, lastReadAt); - return updateFirst(server, query, update).getModifiedCount() > 0; - } - - public void addTicketSubscription(Server server, String email, Staff.TicketSubscription subscription) { - Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); - Update update = new Update().addToSet(StaffFields.SUBSCRIBED_TICKETS, subscription); - updateFirst(server, query, update); - } - - public List findAssignedMinecraftUuids(Server server) { - Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("")); - query.fields().include(StaffFields.ASSIGNED_MINECRAFT_UUID); - return find(server, query) - .stream() - .map(Staff::getAssignedMinecraftUuid) - .filter(uuid -> uuid != null && !uuid.isBlank()) - .distinct() - .toList(); - } - - public Optional findUsernameByEmail(Server server, String email) { - Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); - query.fields().include(StaffFields.USERNAME); - return findOne(server, query) - .map(Staff::getUsername) - .filter(username -> !username.isBlank()); - } - - public List findWithPendingTwoFactorDelivery(Server server) { - Query query = Query.query( - Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) - .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") - ); - return find(server, query); - } - - public void clearPendingTwoFactorDelivery(Server server) { - Query query = Query.query( - Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) - .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") - ); - updateMulti(server, query, new Update().set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, false)); - } - - public List findByUsernames(Server server, Collection usernames) { - if (usernames == null || usernames.isEmpty()) return List.of(); - Query query = Query.query(Criteria.where(StaffFields.USERNAME).in(usernames)); - return find(server, query); - } -} - +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.StaffFields; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.email.EmailAddressUtil; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.bson.Document; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationResults; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class StaffMongoRepository extends AbstractServerMongoRepository { + public StaffMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Staff.class, CollectionName.STAFF, tenantMongoAccess); + } + + public long countAll(Server server) { + return count(server, new Query()); + } + + public Optional findByUsername(Server server, String username) { + return findOne(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); + } + + public Optional findByEmailExact(Server server, String email) { + return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); + } + + public boolean existsByUsername(Server server, String username) { + return exists(server, Query.query(Criteria.where(StaffFields.USERNAME).is(username))); + } + + public boolean existsByEmailOrUsername(Server server, String email, String username) { + Query query = new Query(new Criteria().orOperator( + Criteria.where(StaffFields.EMAIL).is(email), + Criteria.where(StaffFields.USERNAME).is(username) + )); + return exists(server, query); + } + + public boolean existsByEmailExact(Server server, String email) { + return exists(server, Query.query(Criteria.where(StaffFields.EMAIL).is(email))); + } + + public boolean existsByEmailEqualsIgnoreCase(Server server, String email) { + return exists(server, Query.query(Criteria.where(StaffFields.EMAIL) + .regex("^" + Pattern.quote(email) + "$", "i"))); + } + + public boolean existsByEmailIgnoreCaseOrUsername(Server server, String normalizedEmail, String username) { + Query query = new Query(new Criteria().orOperator( + Criteria.where(StaffFields.EMAIL).is(normalizedEmail), + Criteria.where(StaffFields.USERNAME).is(username) + )); + return exists(server, query); + } + + public boolean existsByEmailIgnoreCaseExcluding(Server server, String email, String currentEmail) { + Staff existing = findByEmailIgnoreCase(server, email).orElse(null); + return existing != null && !existing.getEmail().equalsIgnoreCase(currentEmail); + } + + public Optional findByEmailIgnoreCase(Server server, String email) { + return findOne(server, Query.query(Criteria.where(StaffFields.EMAIL) + .is(EmailAddressUtil.normalize(email)))); + } + + public boolean existsByUsernameExcludingId(Server server, String username, String excludedStaffId) { + Query query = Query.query(Criteria.where(StaffFields.USERNAME).is(username) + .and(StaffFields.ID).ne(excludedStaffId)); + return exists(server, query); + } + + public boolean deleteById(Server server, String staffId) { + return remove(server, Query.query(Criteria.where(StaffFields.ID).is(staffId))).getDeletedCount() > 0; + } + + public List findAssignedMinecraftStaff(Server server) { + Query query = Query.query( + Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") + ); + return find(server, query); + } + + public Optional findByAssignedMinecraftUuidExcludingId(Server server, String minecraftUuid, String excludedStaffId) { + Query query = Query.query( + Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid) + .and(StaffFields.ID).ne(excludedStaffId) + ); + return findOne(server, query); + } + + public boolean updateLastSeenByAssignedMinecraftUuid(Server server, String minecraftUuid) { + Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update().set(StaffFields.LAST_SEEN, new Date()); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public int countByRoleId(Server server, String roleId) { + return (int) count(server, Query.query(Criteria.where(StaffFields.ROLE_ID).is(roleId))); + } + + public Map countByRoleId(Server server) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.group(StaffFields.ROLE_ID).count().as("count") + ); + AggregationResults results = aggregate(server, aggregation, Document.class); + Map counts = new LinkedHashMap<>(); + for (Document document : results.getMappedResults()) { + String roleId = document.getString("_id"); + if (roleId != null) { + counts.put(roleId, document.getInteger("count", 0)); + } + } + return counts; + } + + public Map findUsernamesByIds(Server server, Set ids) { + if (ids == null || ids.isEmpty()) { + return Map.of(); + } + Query query = Query.query(Criteria.where(StaffFields.ID).in(ids)); + query.fields().include(StaffFields.USERNAME); + Map result = new HashMap<>(); + for (Staff staff : find(server, query)) { + if (staff.getId() != null && staff.getUsername() != null) { + result.put(staff.getId(), staff.getUsername()); + } + } + return result; + } + + public boolean createTwoFactorToken(Server server, String minecraftUuid, String token, String ip, long createdAt) { + Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).is(minecraftUuid)); + Update update = new Update(); + update.set(StaffFields.TWO_FACTOR_TOKEN, token); + update.set(StaffFields.TWO_FACTOR_TOKEN_IP, ip); + update.set(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT, createdAt); + return updateFirst(server, query, update).getMatchedCount() > 0; + } + + public Optional findByTwoFactorToken(Server server, String token) { + return findOne(server, Query.query(Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token))); + } + + public boolean activateTwoFactorSession(Server server, String staffId, String token, String sessionIp, long sessionExpiresAt) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(StaffFields.ID).is(staffId), + Criteria.where(StaffFields.TWO_FACTOR_TOKEN).is(token) + )); + Update update = new Update(); + update.unset(StaffFields.TWO_FACTOR_TOKEN); + update.unset(StaffFields.TWO_FACTOR_TOKEN_CREATED_AT); + update.set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, true); + update.set(StaffFields.TWO_FACTOR_SESSION_IP, sessionIp); + update.unset(StaffFields.TWO_FACTOR_TOKEN_IP); + update.set(StaffFields.TWO_FACTOR_SESSION_EXPIRES_AT, sessionExpiresAt); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public boolean deactivateSubscription(Server server, String email, String ticketId) { + Query query = Query.query( + Criteria.where(StaffFields.EMAIL).is(email) + .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) + .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) + ); + Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_ACTIVE, false); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public boolean markSubscriptionRead(Server server, String email, String ticketId, java.util.Date lastReadAt) { + Query query = Query.query( + Criteria.where(StaffFields.EMAIL).is(email) + .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) + .and(StaffFields.SUBSCRIBED_TICKET_ACTIVE).is(true) + ); + Update update = new Update().set(StaffFields.SUBSCRIBED_TICKET_POS_LAST_READ_AT, lastReadAt); + return updateFirst(server, query, update).getModifiedCount() > 0; + } + + public void addTicketSubscription(Server server, String email, Staff.TicketSubscription subscription) { + Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); + Update update = new Update().addToSet(StaffFields.SUBSCRIBED_TICKETS, subscription); + updateFirst(server, query, update); + } + + public List findAssignedMinecraftUuids(Server server) { + Query query = Query.query(Criteria.where(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("")); + query.fields().include(StaffFields.ASSIGNED_MINECRAFT_UUID); + return find(server, query) + .stream() + .map(Staff::getAssignedMinecraftUuid) + .filter(uuid -> uuid != null && !uuid.isBlank()) + .distinct() + .toList(); + } + + public Optional findUsernameByEmail(Server server, String email) { + Query query = Query.query(Criteria.where(StaffFields.EMAIL).is(email)); + query.fields().include(StaffFields.USERNAME); + return findOne(server, query) + .map(Staff::getUsername) + .filter(username -> !username.isBlank()); + } + + public List findWithPendingTwoFactorDelivery(Server server) { + Query query = Query.query( + Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) + .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") + ); + return find(server, query); + } + + public void clearPendingTwoFactorDelivery(Server server) { + Query query = Query.query( + Criteria.where(StaffFields.TWO_FACTOR_PENDING_DELIVERY).is(true) + .and(StaffFields.ASSIGNED_MINECRAFT_UUID).exists(true).ne(null).ne("") + ); + updateMulti(server, query, new Update().set(StaffFields.TWO_FACTOR_PENDING_DELIVERY, false)); + } + + public List findByUsernames(Server server, Collection usernames) { + if (usernames == null || usernames.isEmpty()) return List.of(); + Query query = Query.query(Criteria.where(StaffFields.USERNAME).in(usernames)); + return find(server, query); + } +} + diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java index 74472c6..a51a387 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StaffRoleMongoRepository.java @@ -1,142 +1,142 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; - -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.StaffRoleFields; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.server.data.Server; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.regex.Pattern; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; -import org.springframework.data.mongodb.core.MongoTemplate; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class StaffRoleMongoRepository extends AbstractServerMongoRepository { - public StaffRoleMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(StaffRole.class, CollectionName.STAFF_ROLES, tenantMongoAccess); - } - - public List findAllOrdered(Server server) { - Query query = new Query().with(Sort.by( - Sort.Direction.ASC, - StaffRoleFields.ORDER, - StaffRoleFields.CREATED_AT - )); - return find(server, query); - } - - public Optional findHighestOrdered(Server server) { - Query query = new Query().with(Sort.by(Sort.Direction.DESC, StaffRoleFields.ORDER)).limit(1); - return findOne(server, query); - } - - public boolean existsByNameIgnoreCase(Server server, String roleName) { - return exists(server, Query.query( - Criteria.where(StaffRoleFields.NAME).regex("^" + Pattern.quote(roleName) + "$", "i") - )); - } - - public boolean existsByNameIgnoreCaseExcludingId(Server server, String roleName, String excludedRoleId) { - Criteria criteria = Criteria.where(StaffRoleFields.NAME) - .regex("^" + Pattern.quote(roleName) + "$", "i") - .and(StaffRoleFields.ID).ne(excludedRoleId); - return exists(server, Query.query(criteria)); - } - - - public void updateOrder(Server server, String roleId, int order) { - Update update = new Update(); - update.set(StaffRoleFields.ORDER, order); - updateFirst(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId)), update); - } - - public void upsertRole(Server server, StaffRole role) { - Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); - Update update = new Update(); - update.set(StaffRoleFields.NAME, role.getName()); - update.set(StaffRoleFields.DESCRIPTION, role.getDescription()); - update.set(StaffRoleFields.PERMISSIONS, role.getPermissions()); - update.set(StaffRoleFields.IS_DEFAULT, role.isDefault()); - update.set(StaffRoleFields.ORDER, role.getOrder()); - update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); - update.set(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); - upsert(server, query, update); - } - - public void insertRoleIfAbsent(Server server, StaffRole role) { - Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); - Update update = new Update(); - update.setOnInsert(StaffRoleFields.NAME, role.getName()); - update.setOnInsert(StaffRoleFields.DESCRIPTION, role.getDescription()); - update.setOnInsert(StaffRoleFields.PERMISSIONS, role.getPermissions()); - update.setOnInsert(StaffRoleFields.IS_DEFAULT, role.isDefault()); - update.setOnInsert(StaffRoleFields.ORDER, role.getOrder()); - update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); - update.setOnInsert(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); - upsert(server, query, update); - } - - public List findCustomRolesWithOrderZero(Server server) { - Query query = Query.query(Criteria.where(StaffRoleFields.IS_DEFAULT).is(false) - .and(StaffRoleFields.ORDER).is(0)); - return find(server, query); - } - - public boolean deleteById(Server server, String roleId) { - return remove(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId))).getDeletedCount() > 0; - } - - public Optional findByName(Server server, String roleName) { - return findOne(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); - } - - public List findAllByName(Server server, String roleName) { - return find(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); - } - - public List findByIds(Server server, Collection ids) { - if (ids == null || ids.isEmpty()) { - return List.of(); - } - return find(server, Query.query(Criteria.where(StaffRoleFields.ID).in(ids))); - } - - public void bulkUpdateOrder(Server server, Map orderById) { - if (orderById.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (Map.Entry entry : orderById.entrySet()) { - Query query = Query.query(Criteria.where("_id").is(entry.getKey())); - Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); - bulk.updateOne(query, update); - } - bulk.execute(); - } - - public void bulkRepairOrderFromZero(Server server, Map orderById) { - if (orderById.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (Map.Entry entry : orderById.entrySet()) { - Query query = Query.query(Criteria.where("_id").is(entry.getKey()) - .and(StaffRoleFields.ORDER).is(0) - .and(StaffRoleFields.IS_DEFAULT).is(false)); - Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); - bulk.updateOne(query, update); - } - bulk.execute(); - } -} - +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.StaffRoleFields; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.server.data.Server; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.BulkOperations; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class StaffRoleMongoRepository extends AbstractServerMongoRepository { + public StaffRoleMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(StaffRole.class, CollectionName.STAFF_ROLES, tenantMongoAccess); + } + + public List findAllOrdered(Server server) { + Query query = new Query().with(Sort.by( + Sort.Direction.ASC, + StaffRoleFields.ORDER, + StaffRoleFields.CREATED_AT + )); + return find(server, query); + } + + public Optional findHighestOrdered(Server server) { + Query query = new Query().with(Sort.by(Sort.Direction.DESC, StaffRoleFields.ORDER)).limit(1); + return findOne(server, query); + } + + public boolean existsByNameIgnoreCase(Server server, String roleName) { + return exists(server, Query.query( + Criteria.where(StaffRoleFields.NAME).regex("^" + Pattern.quote(roleName) + "$", "i") + )); + } + + public boolean existsByNameIgnoreCaseExcludingId(Server server, String roleName, String excludedRoleId) { + Criteria criteria = Criteria.where(StaffRoleFields.NAME) + .regex("^" + Pattern.quote(roleName) + "$", "i") + .and(StaffRoleFields.ID).ne(excludedRoleId); + return exists(server, Query.query(criteria)); + } + + + public void updateOrder(Server server, String roleId, int order) { + Update update = new Update(); + update.set(StaffRoleFields.ORDER, order); + updateFirst(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId)), update); + } + + public void upsertRole(Server server, StaffRole role) { + Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); + Update update = new Update(); + update.set(StaffRoleFields.NAME, role.getName()); + update.set(StaffRoleFields.DESCRIPTION, role.getDescription()); + update.set(StaffRoleFields.PERMISSIONS, role.getPermissions()); + update.set(StaffRoleFields.IS_DEFAULT, role.isDefault()); + update.set(StaffRoleFields.ORDER, role.getOrder()); + update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); + update.set(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); + upsert(server, query, update); + } + + public void insertRoleIfAbsent(Server server, StaffRole role) { + Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(role.getId())); + Update update = new Update(); + update.setOnInsert(StaffRoleFields.NAME, role.getName()); + update.setOnInsert(StaffRoleFields.DESCRIPTION, role.getDescription()); + update.setOnInsert(StaffRoleFields.PERMISSIONS, role.getPermissions()); + update.setOnInsert(StaffRoleFields.IS_DEFAULT, role.isDefault()); + update.setOnInsert(StaffRoleFields.ORDER, role.getOrder()); + update.setOnInsert(StaffRoleFields.CREATED_AT, role.getCreatedAt()); + update.setOnInsert(StaffRoleFields.UPDATED_AT, role.getUpdatedAt()); + upsert(server, query, update); + } + + public List findCustomRolesWithOrderZero(Server server) { + Query query = Query.query(Criteria.where(StaffRoleFields.IS_DEFAULT).is(false) + .and(StaffRoleFields.ORDER).is(0)); + return find(server, query); + } + + public boolean deleteById(Server server, String roleId) { + return remove(server, Query.query(Criteria.where(StaffRoleFields.ID).is(roleId))).getDeletedCount() > 0; + } + + public Optional findByName(Server server, String roleName) { + return findOne(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); + } + + public List findAllByName(Server server, String roleName) { + return find(server, Query.query(Criteria.where(StaffRoleFields.NAME).is(roleName))); + } + + public List findByIds(Server server, Collection ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + return find(server, Query.query(Criteria.where(StaffRoleFields.ID).in(ids))); + } + + public void bulkUpdateOrder(Server server, Map orderById) { + if (orderById.isEmpty()) return; + + MongoTemplate template = serverTemplate(server); + BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); + for (Map.Entry entry : orderById.entrySet()) { + Query query = Query.query(Criteria.where("_id").is(entry.getKey())); + Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); + bulk.updateOne(query, update); + } + bulk.execute(); + } + + public void bulkRepairOrderFromZero(Server server, Map orderById) { + if (orderById.isEmpty()) return; + + MongoTemplate template = serverTemplate(server); + BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); + for (Map.Entry entry : orderById.entrySet()) { + Query query = Query.query(Criteria.where("_id").is(entry.getKey()) + .and(StaffRoleFields.ORDER).is(0) + .and(StaffRoleFields.IS_DEFAULT).is(false)); + Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); + bulk.updateOne(query, update); + } + bulk.execute(); + } +} + diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java index 8ea845d..4d725f9 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/TicketMongoRepository.java @@ -1,592 +1,592 @@ -package gg.modl.backend.database.mongo.repository; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.AbstractServerMongoRepository; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.fields.TicketFields; -import gg.modl.backend.infrastructure.util.CanonicalAliasIndex; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.AppealWorkflowStatus; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import gg.modl.backend.ticket.util.TicketAssigneeUtil; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.regex.Pattern; -import java.util.stream.Collectors; -import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.query.Criteria; -import org.springframework.data.mongodb.core.query.Query; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.stereotype.Repository; - -@Repository -public class TicketMongoRepository extends AbstractServerMongoRepository { - private static final String FILTER_ALL = "all"; - private static final String FILTER_OPEN = "open"; - private static final String FILTER_CLOSED = "closed"; - private static final String ASSIGNEE_NONE = "none"; - - private static final String SORT_OLDEST = "oldest"; - private static final String SORT_RECENTLY_UPDATED = "recently-updated"; - private static final String SORT_LEAST_RECENTLY_UPDATED = "least-recently-updated"; - - public TicketMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(Ticket.class, CollectionName.TICKETS, tenantMongoAccess); - } - - public void updateState(Server server, Ticket ticket) { - Query query = Query.query(Criteria.where(TicketFields.ID).is(ticket.getId())); - Update update = new Update() - .set(TicketFields.REPLIES, ticket.getReplies()) - .set(TicketFields.LOCKED, ticket.isLocked()) - .set(TicketFields.STATUS, ticket.getStatus() != null ? ticket.getStatus().getId() : null) - .set(TicketFields.UPDATED_AT, ticket.getUpdatedAt()); - updateFirst(server, query, update); - } - - public List findByIds(Server server, List ticketIds) { - return find(server, Query.query(Criteria.where(TicketFields.ID).in(ticketIds))); - } - - public Optional findByTicketId(Server server, String ticketId) { - return findOne(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); - } - - public List findReportedPlayerTickets(Server server, String reportedPlayerUuid, int limit) { - Query query = Query.query(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(reportedPlayerUuid)); - query.limit(limit); - return find(server, query); - } - - public List findMinecraftTickets(Server server, String status, String type, int limit) { - List conditions = new ArrayList<>(); - - if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { - conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); - } - - if (type != null && !type.isBlank()) { - conditions.add(buildTypeCriteria(type)); - } else { - conditions.add(Criteria.where(TicketFields.TYPE).in( - TicketCategory.SUPPORT.getId(), - TicketCategory.BUG.getId(), - TicketCategory.APPEAL.getId() - )); - } - - Query query = conditions.isEmpty() - ? new Query() - : Query.query(new Criteria().andOperator(conditions.toArray(new Criteria[0]))); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - query.limit(Math.min(limit, 100)); - return find(server, query); - } - - private Criteria buildTypeCriteria(String type) { - String normalizedType = normalizeTypeValue(type); - - TicketCategory category = tryResolveCategory(type); - if (category != null && !TicketCategory.isCanonicalBucket(normalizedType)) { - return Criteria.where(TicketFields.TYPE).is(category.getId()); - } - - List bucketCategoryIds = TicketCategory.categoryIdsForBucket(normalizedType); - if (!bucketCategoryIds.isEmpty()) { - return Criteria.where(TicketFields.TYPE).in(bucketCategoryIds); - } - - String escapedType = Pattern.quote(normalizedType); - return Criteria.where(TicketFields.TYPE).regex("^" + escapedType + "$", "i"); - } - - private TicketCategory tryResolveCategory(String rawType) { - if (rawType == null || rawType.isBlank() || FILTER_ALL.equalsIgnoreCase(rawType)) { - return null; - } - try { - return TicketCategory.fromCanonicalId(rawType); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - private String normalizeTypeValue(String type) { - return CanonicalAliasIndex.normalize(type); - } - - public List findRecentByCreator(Server server, String creatorUuid, int limit) { - Query query = Query.query(Criteria.where(TicketFields.CREATOR_UUID).is(creatorUuid)); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - query.limit(Math.min(limit, 50)); - return find(server, query); - } - - public List findPlayerTicketsWithReplayUrl(Server server, String playerUuid, int limit) { - String lower = playerUuid == null ? null : playerUuid.toLowerCase(Locale.ROOT); - String upper = playerUuid == null ? null : playerUuid.toUpperCase(Locale.ROOT); - List uuidCandidates = lower != null && lower.equals(upper) - ? List.of(lower) - : Arrays.asList(lower, upper); - - Query query = Query.query(new Criteria().andOperator( - new Criteria().orOperator( - Criteria.where(TicketFields.CREATOR_UUID).in(uuidCandidates), - Criteria.where(TicketFields.REPORTED_PLAYER_UUID).in(uuidCandidates) - ), - Criteria.where(TicketFields.REPLAY_URL).exists(true).nin(Arrays.asList(null, "")) - )); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - query.limit(Math.min(limit, 100)); - return find(server, query); - } - - public long clearReplayReferences(Server server, Collection replayIds) { - List ids = sanitizeReplayIds(replayIds); - if (ids.isEmpty()) { - return 0L; - } - Query query = Query.query(Criteria.where(TicketFields.REPLAY_ID).in(ids)); - Update update = new Update().unset(TicketFields.REPLAY_URL).unset(TicketFields.REPLAY_ID); - return updateMulti(server, query, update).getModifiedCount(); - } - - public Set findReplayIdsReferencedByUnresolvedTicket(Server server, Collection replayIds) { - List ids = sanitizeReplayIds(replayIds); - if (ids.isEmpty()) { - return Set.of(); - } - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.REPLAY_ID).in(ids), - Criteria.where(TicketFields.STATUS).ne(TicketStatus.CLOSED.getId()) - )); - query.fields().include(TicketFields.REPLAY_ID); - return find(server, query).stream() - .map(Ticket::getReplayId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - private List sanitizeReplayIds(Collection replayIds) { - if (replayIds == null || replayIds.isEmpty()) { - return List.of(); - } - List ids = new ArrayList<>(replayIds.size()); - for (String replayId : replayIds) { - if (replayId != null && !replayId.isBlank()) { - ids.add(replayId); - } - } - return ids; - } - - public List findReports(Server server, String status, String playerUuid, int limit, boolean sortByCreatedDesc) { - Query query = Query.query(buildReportCriteria(status, playerUuid)); - if (sortByCreatedDesc) { - query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - } - query.limit(Math.min(limit, 100)); - return find(server, query); - } - - private Criteria buildReportCriteria(String status, String playerUuid) { - List conditions = new ArrayList<>(); - conditions.add(Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds())); - - if (playerUuid != null && !playerUuid.isBlank()) { - conditions.add(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid)); - } - - if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { - conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); - } - - return conditions.size() == 1 - ? conditions.get(0) - : new Criteria().andOperator(conditions.toArray(new Criteria[0])); - } - - public TicketSearchPage searchTickets(Server server, TicketSearchFilter filter, TicketSortOption sort, int page, int limit) { - Query countQuery = buildSearchQuery(filter, true); - long total = count(server, countQuery); - - Query pagedQuery = Query.of(countQuery); - pagedQuery.with(sort.toMongoSort()); - pagedQuery.skip(Math.max(page - 1, 0L) * limit).limit(limit); - List tickets = find(server, pagedQuery); - return new TicketSearchPage(tickets, total); - } - - private Query buildSearchQuery(TicketSearchFilter filter, boolean includeReplySearch) { - TicketStatus requestedStatus = tryResolveTicketStatus(filter.status()); - Query query = new Query(); - if (requestedStatus != TicketStatus.UNFINISHED) { - query.addCriteria(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())); - } - - if (filter.search() != null && !filter.search().isBlank()) { - String escapedSearch = Pattern.quote(filter.search()); - List searchCriteria = new ArrayList<>(); - searchCriteria.add(Criteria.where(TicketFields.ID).regex(escapedSearch, "i")); - searchCriteria.add(Criteria.where(TicketFields.SUBJECT).regex(escapedSearch, "i")); - searchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedSearch, "i")); - if (includeReplySearch) { - searchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).regex(escapedSearch, "i")); - searchCriteria.add(Criteria.where(TicketFields.REPLY_CONTENT).regex(escapedSearch, "i")); - } - query.addCriteria(new Criteria().orOperator(searchCriteria.toArray(new Criteria[0]))); - } - - if (filter.status() != null && !filter.status().isBlank() && !filter.status().equalsIgnoreCase(FILTER_ALL)) { - if (requestedStatus == TicketStatus.UNFINISHED) { - query.addCriteria(Criteria.where(TicketFields.STATUS).is(TicketStatus.UNFINISHED.getId())); - } else if (requestedStatus != null) { - query.addCriteria(requestedStatus.isTerminal() - ? Criteria.where(TicketFields.LOCKED).is(true) - : Criteria.where(TicketFields.LOCKED).ne(true)); - } else if (filter.status().equalsIgnoreCase(FILTER_OPEN)) { - query.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); - } else if (filter.status().equalsIgnoreCase(FILTER_CLOSED)) { - query.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); - } - } - - if (filter.types() != null && !filter.types().isEmpty()) { - List validTypes = filter.types() - .stream() - .filter(type -> type != null && !type.isBlank() && !type.equals(FILTER_ALL)) - .toList(); - if (!validTypes.isEmpty()) { - List typeCriteria = validTypes.stream() - .map(this::buildTypeCriteria) - .toList(); - query.addCriteria(new Criteria().orOperator(typeCriteria.toArray(new Criteria[0]))); - } - } - - if (filter.author() != null && !filter.author().isBlank()) { - String escapedAuthor = Pattern.quote(filter.author()); - query.addCriteria(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedAuthor, "i")); - } - - if (filter.labels() != null && !filter.labels().isEmpty()) { - query.addCriteria(Criteria.where(TicketFields.TAGS).all(filter.labels())); - } - - Criteria assigneeCriteria = buildAssigneeCriteria(filter.assignees()); - if (assigneeCriteria != null) { - query.addCriteria(assigneeCriteria); - } - - return query; - } - - private Criteria buildAssigneeCriteria(List assignees) { - if (assignees == null || assignees.isEmpty()) { - return null; - } - - List assigneeCriteriaList = new ArrayList<>(); - for (String assignee : assignees) { - if (assignee == null || assignee.isBlank()) { - continue; - } - - if (ASSIGNEE_NONE.equalsIgnoreCase(assignee)) { - assigneeCriteriaList.add(buildUnassignedCriteria()); - continue; - } - - String normalizedAssignee = TicketAssigneeUtil.normalizeSingle(assignee); - if (normalizedAssignee != null) { - assigneeCriteriaList.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedAssignee)); - } - } - - if (assigneeCriteriaList.isEmpty()) { - return null; - } - - return new Criteria().orOperator(assigneeCriteriaList.toArray(new Criteria[0])); - } - - private Criteria buildUnassignedCriteria() { - return new Criteria().orOperator( - Criteria.where(TicketFields.ASSIGNED_TO).exists(false), - Criteria.where(TicketFields.ASSIGNED_TO).is(null), - Criteria.where(TicketFields.ASSIGNED_TO).size(0) - ); - } - - private TicketStatus tryResolveTicketStatus(String rawStatus) { - if (rawStatus == null || rawStatus.isBlank() || FILTER_ALL.equalsIgnoreCase(rawStatus)) { - return null; - } - try { - return TicketStatus.fromCanonicalId(rawStatus); - } catch (IllegalArgumentException ignored) { - return null; - } - } - - public TicketCounts countTickets(Server server, TicketSearchFilter filter) { - Query openQuery = buildSearchQuery(filter, false); - openQuery.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); - - Query closedQuery = buildSearchQuery(filter, false); - closedQuery.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); - - return new TicketCounts(count(server, openQuery), count(server, closedQuery)); - } - - public List findByPlayer(Server server, String playerUuid) { - Criteria criteria = new Criteria().andOperator( - new Criteria().orOperator( - Criteria.where(TicketFields.CREATOR_UUID).is(playerUuid), - Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid) - ), - Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - ); - Query query = Query.query(criteria).with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); - return find(server, query); - } - - public List findByTag(Server server, String tag) { - return find(server, Query.query(Criteria.where(TicketFields.TAGS).is(tag))); - } - - public List findRecentActiveTicketsWithRepliesByIds(Server server, List ticketIds, int limit) { - if (ticketIds == null || ticketIds.isEmpty()) { - return List.of(); - } - - Query query = Query.query( - Criteria.where(TicketFields.ID).in(ticketIds) - .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - .and(TicketFields.REPLIES + ".0").exists(true) - ); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); - query.limit(limit); - return find(server, query); - } - - public List findRecentAssignedTicketsWithReplies(Server server, String assignee, int limit) { - Query query = Query.query( - Criteria.where(TicketFields.ASSIGNED_TO).is(assignee) - .and(TicketFields.REPLIES + ".0").exists(true) - .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - ); - query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); - query.limit(limit); - return find(server, query); - } - - public boolean existsByTicketId(Server server, String ticketId) { - return exists(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); - } - - public List findAppealsByPunishmentId(Server server, String punishmentId) { - Query query = Query.query( - Criteria.where(TicketFields.TYPE).is(TicketCategory.APPEAL.getId()) - .and(TicketFields.DATA + ".punishmentId").is(punishmentId) - ); - return find(server, query); - } - - public Ticket saveAppeal(Server server, Ticket appeal) { - return saveEntity(server, appeal); - } - - public Ticket insertTicket(Server server, Ticket ticket) { - return insert(serverTemplate(server), ticket); - } - - public void pushReply(Server server, String ticketId, TicketReply reply) { - Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); - Update update = new Update() - .push(TicketFields.REPLIES, reply) - .set(TicketFields.UPDATED_AT, new Date()); - updateFirst(server, query, update); - } - - public void updateAppealState(Server server, String ticketId, - AppealWorkflowStatus appealWorkflowStatus, - TicketStatus status, Boolean locked, - Map data, - List systemReplies) { - Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); - Update update = new Update().set(TicketFields.UPDATED_AT, new Date()); - - if (appealWorkflowStatus != null) { - update.set(TicketFields.APPEAL_WORKFLOW_STATUS, appealWorkflowStatus.getId()); - } - if (status != null) { - update.set(TicketFields.STATUS, status.getId()); - } - if (locked != null) { - update.set(TicketFields.LOCKED, locked); - } - if (data != null) { - update.set(TicketFields.DATA, data); - } - if (systemReplies != null) { - for (TicketReply reply : systemReplies) { - update.push(TicketFields.REPLIES, reply); - } - } - updateFirst(server, query, update); - } - - public List findCreatedAfterExcludingUnfinished(Server server, Date after, int limit) { - Query query = Query.query( - Criteria.where(TicketFields.CREATED).gte(after) - .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) - ); - query.limit(limit); - return find(server, query); - } - - public long countUnresolvedReports(Server server) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds()), - Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) - )); - return count(server, query); - } - - public long countUnresolvedTickets(Server server) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.TYPE).in( - TicketCategory.SUPPORT.getId(), - TicketCategory.BUG.getId(), - TicketCategory.APPEAL.getId() - ), - Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) - )); - return count(server, query); - } - - public long countAll(Server server) { - return count(server, new Query()); - } - - public long countByStatus(Server server, TicketStatus status) { - return count(server, Query.query(Criteria.where(TicketFields.STATUS).is(status.getId()))); - } - - public long countCreatedAfter(Server server, Date after) { - return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(after))); - } - - public long countCreatedBetween(Server server, Date from, Date to) { - return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(from).lt(to))); - } - - public List findRecentWithProjection(Server server, int limit) { - Query query = Query.query(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())) - .with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)) - .limit(limit); - - query.fields() - .include(TicketFields.SUBJECT) - .include(TicketFields.STATUS) - .include(TicketFields.PRIORITY) - .include(TicketFields.CREATED) - .include(TicketFields.CREATOR_NAME) - .include(TicketFields.TYPE) - .include(TicketFields.REPLIES); - query.fields().slice(TicketFields.REPLIES, 1); - - return find(server, query); - } - - public List findStaffActivityTickets(Server server, String staffUsername, String normalizedStaffUsername, Date cutoffDate, int limit) { - List staffMatchCriteria = new ArrayList<>(); - staffMatchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).is(staffUsername)); - if (normalizedStaffUsername != null) { - staffMatchCriteria.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedStaffUsername)); - } - staffMatchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).is(staffUsername)); - - Query query = Query.query(new Criteria().andOperator( - Criteria.where(TicketFields.UPDATED_AT).gte(cutoffDate), - new Criteria().orOperator(staffMatchCriteria.toArray(new Criteria[0])) - )).with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)).limit(limit); - - query.fields() - .include(TicketFields.SUBJECT) - .include(TicketFields.TYPE) - .include(TicketFields.CREATED) - .include(TicketFields.CREATOR_NAME) - .include(TicketFields.REPLY_NAME) - .include(TicketFields.REPLY_CREATED); - - return find(server, query); - } - - public enum TicketSortOption { - NEWEST(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)), - OLDEST(Sort.by(Sort.Direction.ASC, TicketFields.CREATED)), - RECENTLY_UPDATED(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)), - LEAST_RECENTLY_UPDATED(Sort.by(Sort.Direction.ASC, TicketFields.UPDATED_AT)); - - private final Sort mongoSort; - - TicketSortOption(Sort mongoSort) { - this.mongoSort = mongoSort; - } - - public static TicketSortOption from(String rawSort) { - if (rawSort == null || rawSort.isBlank()) { - return NEWEST; - } - return switch (rawSort) { - case SORT_OLDEST -> OLDEST; - case SORT_RECENTLY_UPDATED -> RECENTLY_UPDATED; - case SORT_LEAST_RECENTLY_UPDATED -> LEAST_RECENTLY_UPDATED; - default -> NEWEST; - }; - } - - public Sort toMongoSort() { - return mongoSort; - } - } - - public record TicketSearchFilter( - String search, - String status, - List types, - String author, - List labels, - List assignees - ) {} - - public record TicketSearchPage(List tickets, long total) {} - - public record TicketCounts(long open, long closed) {} - - public void bulkCloseForPunishment(Server server, List ticketIds) { - if (ticketIds == null || ticketIds.isEmpty()) return; - Query query = Query.query( - Criteria.where(TicketFields.ID).in(ticketIds) - .and(TicketFields.LOCKED).ne(true) - ); - Update update = new Update() - .set(TicketFields.STATUS, TicketStatus.CLOSED.getId()) - .set(TicketFields.LOCKED, true) - .set(TicketFields.UPDATED_AT, new Date()); - updateMulti(server, query, update); - } -} +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.AbstractServerMongoRepository; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.TicketFields; +import gg.modl.backend.infrastructure.util.CanonicalAliasIndex; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.AppealWorkflowStatus; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.ticket.util.TicketAssigneeUtil; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +@Repository +public class TicketMongoRepository extends AbstractServerMongoRepository { + private static final String FILTER_ALL = "all"; + private static final String FILTER_OPEN = "open"; + private static final String FILTER_CLOSED = "closed"; + private static final String ASSIGNEE_NONE = "none"; + + private static final String SORT_OLDEST = "oldest"; + private static final String SORT_RECENTLY_UPDATED = "recently-updated"; + private static final String SORT_LEAST_RECENTLY_UPDATED = "least-recently-updated"; + + public TicketMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(Ticket.class, CollectionName.TICKETS, tenantMongoAccess); + } + + public void updateState(Server server, Ticket ticket) { + Query query = Query.query(Criteria.where(TicketFields.ID).is(ticket.getId())); + Update update = new Update() + .set(TicketFields.REPLIES, ticket.getReplies()) + .set(TicketFields.LOCKED, ticket.isLocked()) + .set(TicketFields.STATUS, ticket.getStatus() != null ? ticket.getStatus().getId() : null) + .set(TicketFields.UPDATED_AT, ticket.getUpdatedAt()); + updateFirst(server, query, update); + } + + public List findByIds(Server server, List ticketIds) { + return find(server, Query.query(Criteria.where(TicketFields.ID).in(ticketIds))); + } + + public Optional findByTicketId(Server server, String ticketId) { + return findOne(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); + } + + public List findReportedPlayerTickets(Server server, String reportedPlayerUuid, int limit) { + Query query = Query.query(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(reportedPlayerUuid)); + query.limit(limit); + return find(server, query); + } + + public List findMinecraftTickets(Server server, String status, String type, int limit) { + List conditions = new ArrayList<>(); + + if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { + conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); + } + + if (type != null && !type.isBlank()) { + conditions.add(buildTypeCriteria(type)); + } else { + conditions.add(Criteria.where(TicketFields.TYPE).in( + TicketCategory.SUPPORT.getId(), + TicketCategory.BUG.getId(), + TicketCategory.APPEAL.getId() + )); + } + + Query query = conditions.isEmpty() + ? new Query() + : Query.query(new Criteria().andOperator(conditions.toArray(new Criteria[0]))); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + query.limit(Math.min(limit, 100)); + return find(server, query); + } + + private Criteria buildTypeCriteria(String type) { + String normalizedType = normalizeTypeValue(type); + + TicketCategory category = tryResolveCategory(type); + if (category != null && !TicketCategory.isCanonicalBucket(normalizedType)) { + return Criteria.where(TicketFields.TYPE).is(category.getId()); + } + + List bucketCategoryIds = TicketCategory.categoryIdsForBucket(normalizedType); + if (!bucketCategoryIds.isEmpty()) { + return Criteria.where(TicketFields.TYPE).in(bucketCategoryIds); + } + + String escapedType = Pattern.quote(normalizedType); + return Criteria.where(TicketFields.TYPE).regex("^" + escapedType + "$", "i"); + } + + private TicketCategory tryResolveCategory(String rawType) { + if (rawType == null || rawType.isBlank() || FILTER_ALL.equalsIgnoreCase(rawType)) { + return null; + } + try { + return TicketCategory.fromCanonicalId(rawType); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private String normalizeTypeValue(String type) { + return CanonicalAliasIndex.normalize(type); + } + + public List findRecentByCreator(Server server, String creatorUuid, int limit) { + Query query = Query.query(Criteria.where(TicketFields.CREATOR_UUID).is(creatorUuid)); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + query.limit(Math.min(limit, 50)); + return find(server, query); + } + + public List findPlayerTicketsWithReplayUrl(Server server, String playerUuid, int limit) { + String lower = playerUuid == null ? null : playerUuid.toLowerCase(Locale.ROOT); + String upper = playerUuid == null ? null : playerUuid.toUpperCase(Locale.ROOT); + List uuidCandidates = lower != null && lower.equals(upper) + ? List.of(lower) + : Arrays.asList(lower, upper); + + Query query = Query.query(new Criteria().andOperator( + new Criteria().orOperator( + Criteria.where(TicketFields.CREATOR_UUID).in(uuidCandidates), + Criteria.where(TicketFields.REPORTED_PLAYER_UUID).in(uuidCandidates) + ), + Criteria.where(TicketFields.REPLAY_URL).exists(true).nin(Arrays.asList(null, "")) + )); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + query.limit(Math.min(limit, 100)); + return find(server, query); + } + + public long clearReplayReferences(Server server, Collection replayIds) { + List ids = sanitizeReplayIds(replayIds); + if (ids.isEmpty()) { + return 0L; + } + Query query = Query.query(Criteria.where(TicketFields.REPLAY_ID).in(ids)); + Update update = new Update().unset(TicketFields.REPLAY_URL).unset(TicketFields.REPLAY_ID); + return updateMulti(server, query, update).getModifiedCount(); + } + + public Set findReplayIdsReferencedByUnresolvedTicket(Server server, Collection replayIds) { + List ids = sanitizeReplayIds(replayIds); + if (ids.isEmpty()) { + return Set.of(); + } + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.REPLAY_ID).in(ids), + Criteria.where(TicketFields.STATUS).ne(TicketStatus.CLOSED.getId()) + )); + query.fields().include(TicketFields.REPLAY_ID); + return find(server, query).stream() + .map(Ticket::getReplayId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + } + + private List sanitizeReplayIds(Collection replayIds) { + if (replayIds == null || replayIds.isEmpty()) { + return List.of(); + } + List ids = new ArrayList<>(replayIds.size()); + for (String replayId : replayIds) { + if (replayId != null && !replayId.isBlank()) { + ids.add(replayId); + } + } + return ids; + } + + public List findReports(Server server, String status, String playerUuid, int limit, boolean sortByCreatedDesc) { + Query query = Query.query(buildReportCriteria(status, playerUuid)); + if (sortByCreatedDesc) { + query.with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + } + query.limit(Math.min(limit, 100)); + return find(server, query); + } + + private Criteria buildReportCriteria(String status, String playerUuid) { + List conditions = new ArrayList<>(); + conditions.add(Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds())); + + if (playerUuid != null && !playerUuid.isBlank()) { + conditions.add(Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid)); + } + + if (status != null && !status.isBlank() && !FILTER_ALL.equalsIgnoreCase(status)) { + conditions.add(Criteria.where(TicketFields.STATUS).is(TicketStatus.fromCanonicalId(status).getId())); + } + + return conditions.size() == 1 + ? conditions.get(0) + : new Criteria().andOperator(conditions.toArray(new Criteria[0])); + } + + public TicketSearchPage searchTickets(Server server, TicketSearchFilter filter, TicketSortOption sort, int page, int limit) { + Query countQuery = buildSearchQuery(filter, true); + long total = count(server, countQuery); + + Query pagedQuery = Query.of(countQuery); + pagedQuery.with(sort.toMongoSort()); + pagedQuery.skip(Math.max(page - 1, 0L) * limit).limit(limit); + List tickets = find(server, pagedQuery); + return new TicketSearchPage(tickets, total); + } + + private Query buildSearchQuery(TicketSearchFilter filter, boolean includeReplySearch) { + TicketStatus requestedStatus = tryResolveTicketStatus(filter.status()); + Query query = new Query(); + if (requestedStatus != TicketStatus.UNFINISHED) { + query.addCriteria(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())); + } + + if (filter.search() != null && !filter.search().isBlank()) { + String escapedSearch = Pattern.quote(filter.search()); + List searchCriteria = new ArrayList<>(); + searchCriteria.add(Criteria.where(TicketFields.ID).regex(escapedSearch, "i")); + searchCriteria.add(Criteria.where(TicketFields.SUBJECT).regex(escapedSearch, "i")); + searchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedSearch, "i")); + if (includeReplySearch) { + searchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).regex(escapedSearch, "i")); + searchCriteria.add(Criteria.where(TicketFields.REPLY_CONTENT).regex(escapedSearch, "i")); + } + query.addCriteria(new Criteria().orOperator(searchCriteria.toArray(new Criteria[0]))); + } + + if (filter.status() != null && !filter.status().isBlank() && !filter.status().equalsIgnoreCase(FILTER_ALL)) { + if (requestedStatus == TicketStatus.UNFINISHED) { + query.addCriteria(Criteria.where(TicketFields.STATUS).is(TicketStatus.UNFINISHED.getId())); + } else if (requestedStatus != null) { + query.addCriteria(requestedStatus.isTerminal() + ? Criteria.where(TicketFields.LOCKED).is(true) + : Criteria.where(TicketFields.LOCKED).ne(true)); + } else if (filter.status().equalsIgnoreCase(FILTER_OPEN)) { + query.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); + } else if (filter.status().equalsIgnoreCase(FILTER_CLOSED)) { + query.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); + } + } + + if (filter.types() != null && !filter.types().isEmpty()) { + List validTypes = filter.types() + .stream() + .filter(type -> type != null && !type.isBlank() && !type.equals(FILTER_ALL)) + .toList(); + if (!validTypes.isEmpty()) { + List typeCriteria = validTypes.stream() + .map(this::buildTypeCriteria) + .toList(); + query.addCriteria(new Criteria().orOperator(typeCriteria.toArray(new Criteria[0]))); + } + } + + if (filter.author() != null && !filter.author().isBlank()) { + String escapedAuthor = Pattern.quote(filter.author()); + query.addCriteria(Criteria.where(TicketFields.CREATOR_NAME).regex(escapedAuthor, "i")); + } + + if (filter.labels() != null && !filter.labels().isEmpty()) { + query.addCriteria(Criteria.where(TicketFields.TAGS).all(filter.labels())); + } + + Criteria assigneeCriteria = buildAssigneeCriteria(filter.assignees()); + if (assigneeCriteria != null) { + query.addCriteria(assigneeCriteria); + } + + return query; + } + + private Criteria buildAssigneeCriteria(List assignees) { + if (assignees == null || assignees.isEmpty()) { + return null; + } + + List assigneeCriteriaList = new ArrayList<>(); + for (String assignee : assignees) { + if (assignee == null || assignee.isBlank()) { + continue; + } + + if (ASSIGNEE_NONE.equalsIgnoreCase(assignee)) { + assigneeCriteriaList.add(buildUnassignedCriteria()); + continue; + } + + String normalizedAssignee = TicketAssigneeUtil.normalizeSingle(assignee); + if (normalizedAssignee != null) { + assigneeCriteriaList.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedAssignee)); + } + } + + if (assigneeCriteriaList.isEmpty()) { + return null; + } + + return new Criteria().orOperator(assigneeCriteriaList.toArray(new Criteria[0])); + } + + private Criteria buildUnassignedCriteria() { + return new Criteria().orOperator( + Criteria.where(TicketFields.ASSIGNED_TO).exists(false), + Criteria.where(TicketFields.ASSIGNED_TO).is(null), + Criteria.where(TicketFields.ASSIGNED_TO).size(0) + ); + } + + private TicketStatus tryResolveTicketStatus(String rawStatus) { + if (rawStatus == null || rawStatus.isBlank() || FILTER_ALL.equalsIgnoreCase(rawStatus)) { + return null; + } + try { + return TicketStatus.fromCanonicalId(rawStatus); + } catch (IllegalArgumentException ignored) { + return null; + } + } + + public TicketCounts countTickets(Server server, TicketSearchFilter filter) { + Query openQuery = buildSearchQuery(filter, false); + openQuery.addCriteria(Criteria.where(TicketFields.LOCKED).ne(true)); + + Query closedQuery = buildSearchQuery(filter, false); + closedQuery.addCriteria(Criteria.where(TicketFields.LOCKED).is(true)); + + return new TicketCounts(count(server, openQuery), count(server, closedQuery)); + } + + public List findByPlayer(Server server, String playerUuid) { + Criteria criteria = new Criteria().andOperator( + new Criteria().orOperator( + Criteria.where(TicketFields.CREATOR_UUID).is(playerUuid), + Criteria.where(TicketFields.REPORTED_PLAYER_UUID).is(playerUuid) + ), + Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + ); + Query query = Query.query(criteria).with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)); + return find(server, query); + } + + public List findByTag(Server server, String tag) { + return find(server, Query.query(Criteria.where(TicketFields.TAGS).is(tag))); + } + + public List findRecentActiveTicketsWithRepliesByIds(Server server, List ticketIds, int limit) { + if (ticketIds == null || ticketIds.isEmpty()) { + return List.of(); + } + + Query query = Query.query( + Criteria.where(TicketFields.ID).in(ticketIds) + .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + .and(TicketFields.REPLIES + ".0").exists(true) + ); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); + query.limit(limit); + return find(server, query); + } + + public List findRecentAssignedTicketsWithReplies(Server server, String assignee, int limit) { + Query query = Query.query( + Criteria.where(TicketFields.ASSIGNED_TO).is(assignee) + .and(TicketFields.REPLIES + ".0").exists(true) + .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + ); + query.with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)); + query.limit(limit); + return find(server, query); + } + + public boolean existsByTicketId(Server server, String ticketId) { + return exists(server, Query.query(Criteria.where(TicketFields.ID).is(ticketId))); + } + + public List findAppealsByPunishmentId(Server server, String punishmentId) { + Query query = Query.query( + Criteria.where(TicketFields.TYPE).is(TicketCategory.APPEAL.getId()) + .and(TicketFields.DATA + ".punishmentId").is(punishmentId) + ); + return find(server, query); + } + + public Ticket saveAppeal(Server server, Ticket appeal) { + return saveEntity(server, appeal); + } + + public Ticket insertTicket(Server server, Ticket ticket) { + return insert(serverTemplate(server), ticket); + } + + public void pushReply(Server server, String ticketId, TicketReply reply) { + Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); + Update update = new Update() + .push(TicketFields.REPLIES, reply) + .set(TicketFields.UPDATED_AT, new Date()); + updateFirst(server, query, update); + } + + public void updateAppealState(Server server, String ticketId, + AppealWorkflowStatus appealWorkflowStatus, + TicketStatus status, Boolean locked, + Map data, + List systemReplies) { + Query query = Query.query(Criteria.where(TicketFields.ID).is(ticketId)); + Update update = new Update().set(TicketFields.UPDATED_AT, new Date()); + + if (appealWorkflowStatus != null) { + update.set(TicketFields.APPEAL_WORKFLOW_STATUS, appealWorkflowStatus.getId()); + } + if (status != null) { + update.set(TicketFields.STATUS, status.getId()); + } + if (locked != null) { + update.set(TicketFields.LOCKED, locked); + } + if (data != null) { + update.set(TicketFields.DATA, data); + } + if (systemReplies != null) { + for (TicketReply reply : systemReplies) { + update.push(TicketFields.REPLIES, reply); + } + } + updateFirst(server, query, update); + } + + public List findCreatedAfterExcludingUnfinished(Server server, Date after, int limit) { + Query query = Query.query( + Criteria.where(TicketFields.CREATED).gte(after) + .and(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId()) + ); + query.limit(limit); + return find(server, query); + } + + public long countUnresolvedReports(Server server) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.TYPE).in(TicketCategory.reportCategoryIds()), + Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) + )); + return count(server, query); + } + + public long countUnresolvedTickets(Server server) { + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.TYPE).in( + TicketCategory.SUPPORT.getId(), + TicketCategory.BUG.getId(), + TicketCategory.APPEAL.getId() + ), + Criteria.where(TicketFields.STATUS).in(TicketStatus.OPEN.getId(), TicketStatus.UNFINISHED.getId()) + )); + return count(server, query); + } + + public long countAll(Server server) { + return count(server, new Query()); + } + + public long countByStatus(Server server, TicketStatus status) { + return count(server, Query.query(Criteria.where(TicketFields.STATUS).is(status.getId()))); + } + + public long countCreatedAfter(Server server, Date after) { + return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(after))); + } + + public long countCreatedBetween(Server server, Date from, Date to) { + return count(server, Query.query(Criteria.where(TicketFields.CREATED).gte(from).lt(to))); + } + + public List findRecentWithProjection(Server server, int limit) { + Query query = Query.query(Criteria.where(TicketFields.STATUS).ne(TicketStatus.UNFINISHED.getId())) + .with(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)) + .limit(limit); + + query.fields() + .include(TicketFields.SUBJECT) + .include(TicketFields.STATUS) + .include(TicketFields.PRIORITY) + .include(TicketFields.CREATED) + .include(TicketFields.CREATOR_NAME) + .include(TicketFields.TYPE) + .include(TicketFields.REPLIES); + query.fields().slice(TicketFields.REPLIES, 1); + + return find(server, query); + } + + public List findStaffActivityTickets(Server server, String staffUsername, String normalizedStaffUsername, Date cutoffDate, int limit) { + List staffMatchCriteria = new ArrayList<>(); + staffMatchCriteria.add(Criteria.where(TicketFields.CREATOR_NAME).is(staffUsername)); + if (normalizedStaffUsername != null) { + staffMatchCriteria.add(Criteria.where(TicketFields.ASSIGNED_TO).is(normalizedStaffUsername)); + } + staffMatchCriteria.add(Criteria.where(TicketFields.REPLY_NAME).is(staffUsername)); + + Query query = Query.query(new Criteria().andOperator( + Criteria.where(TicketFields.UPDATED_AT).gte(cutoffDate), + new Criteria().orOperator(staffMatchCriteria.toArray(new Criteria[0])) + )).with(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)).limit(limit); + + query.fields() + .include(TicketFields.SUBJECT) + .include(TicketFields.TYPE) + .include(TicketFields.CREATED) + .include(TicketFields.CREATOR_NAME) + .include(TicketFields.REPLY_NAME) + .include(TicketFields.REPLY_CREATED); + + return find(server, query); + } + + public enum TicketSortOption { + NEWEST(Sort.by(Sort.Direction.DESC, TicketFields.CREATED)), + OLDEST(Sort.by(Sort.Direction.ASC, TicketFields.CREATED)), + RECENTLY_UPDATED(Sort.by(Sort.Direction.DESC, TicketFields.UPDATED_AT)), + LEAST_RECENTLY_UPDATED(Sort.by(Sort.Direction.ASC, TicketFields.UPDATED_AT)); + + private final Sort mongoSort; + + TicketSortOption(Sort mongoSort) { + this.mongoSort = mongoSort; + } + + public static TicketSortOption from(String rawSort) { + if (rawSort == null || rawSort.isBlank()) { + return NEWEST; + } + return switch (rawSort) { + case SORT_OLDEST -> OLDEST; + case SORT_RECENTLY_UPDATED -> RECENTLY_UPDATED; + case SORT_LEAST_RECENTLY_UPDATED -> LEAST_RECENTLY_UPDATED; + default -> NEWEST; + }; + } + + public Sort toMongoSort() { + return mongoSort; + } + } + + public record TicketSearchFilter( + String search, + String status, + List types, + String author, + List labels, + List assignees + ) {} + + public record TicketSearchPage(List tickets, long total) {} + + public record TicketCounts(long open, long closed) {} + + public void bulkCloseForPunishment(Server server, List ticketIds) { + if (ticketIds == null || ticketIds.isEmpty()) return; + Query query = Query.query( + Criteria.where(TicketFields.ID).in(ticketIds) + .and(TicketFields.LOCKED).ne(true) + ); + Update update = new Update() + .set(TicketFields.STATUS, TicketStatus.CLOSED.getId()) + .set(TicketFields.LOCKED, true) + .set(TicketFields.UPDATED_AT, new Date()); + updateMulti(server, query, update); + } +} diff --git a/src/main/java/gg/modl/backend/email/EmailConfiguration.java b/src/main/java/gg/modl/backend/email/EmailConfiguration.java index 1e91275..82efbe7 100644 --- a/src/main/java/gg/modl/backend/email/EmailConfiguration.java +++ b/src/main/java/gg/modl/backend/email/EmailConfiguration.java @@ -1,17 +1,17 @@ -package gg.modl.backend.email; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import org.springframework.validation.annotation.Validated; - -@Component -@ConfigurationProperties(prefix = "modl.email") -@Validated -@Getter -@Setter -public class EmailConfiguration { - private String fromName; - private String fromEmailAddress; -} +package gg.modl.backend.email; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@ConfigurationProperties(prefix = "modl.email") +@Validated +@Getter +@Setter +public class EmailConfiguration { + private String fromName; + private String fromEmailAddress; +} diff --git a/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java b/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java index 6b43405..9579ab0 100644 --- a/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java +++ b/src/main/java/gg/modl/backend/email/EmailHTMLTemplate.java @@ -1,344 +1,344 @@ -package gg.modl.backend.email; - -import static gg.modl.backend.Constants.BRAND_NAME; - -public interface EmailHTMLTemplate { - CodeTemplate USER_CODE = (serverName, code) -> new HTMLEmail( - "%s | Login Code".formatted(serverName), - """ -
-
-

Login Verification Code

- -

- Your login verification code for %s is: -

- -
-

%s

-
- -

- This code will expire in 15 minutes. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, code) - ); - - CodeTemplate ADMIN_CODE = (serverName, code) -> new HTMLEmail( - BRAND_NAME + " | Admin Login Code", - """ -
-
-

Admin Verification Code

- -

- Your admin panel login code is: -

- -
-

%s

-
- -

- This code will expire in 15 minutes. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(code)); - - CodeTemplate EMAIL_CHANGE_CODE = (serverName, code) -> new HTMLEmail( - "%s | Confirm your new email".formatted(serverName), - """ -
-
-

Confirm Your New Email

- -

- Use this code to confirm your new email address for %s: -

- -
-

%s

-
- -

- This code will expire shortly. If you did not request an email change, you can ignore this message and your address will stay the same. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, code) - ); - - EmailChangedNoticeTemplate EMAIL_CHANGED_NOTICE = (serverName, newEmail) -> new HTMLEmail( - "%s | Your email address was changed".formatted(serverName), - """ -
-
-

Email Address Changed

- -

- The email address for your %s staff account was just changed to %s. -

- -

- If you made this change, no action is needed. If you did not, contact your server administrator immediately. Your account security may be at risk. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, newEmail) - ); - - VerifyLinkTemplate REGISTRATION_VERIFY_LINK = link -> new HTMLEmail( - BRAND_NAME + " | Verify your email", - """ -
-
-

Verify Your Email

- -

- Thank you for registering with %s! -

- -

- Please verify your email address by clicking the button below: -

- - - -

- Or copy and paste this link into your browser: -

-

%s

- -
-

- If you didn't create an account, you can safely ignore this email. -

-
-
-
- """.formatted(BRAND_NAME, link, link)); - - BetaReadyTemplate BETA_PANEL_READY = (serverName, panelLink) -> new HTMLEmail( - "%s | Your beta panel is ready".formatted(serverName), - """ -
-
-

Welcome to the %s Beta

- -

- Your beta tester panel for %s is provisioned and ready to use. -

- -
-

Premium unlocked

-

You've been granted Premium access for free while you help us test. Some usage limits apply during the beta.

-
- - - -

- Or copy and paste this link into your browser: -

-

%s

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(BRAND_NAME, serverName, panelLink, panelLink)); - - StaffInviteTemplate STAFF_INVITE_TEMPLATE = (serverName, staffRole, link) -> new HTMLEmail( - "%s | Staff Invitation".formatted(serverName), - """ -
-
-

Team Invitation

- -

- You have been invited to join the %s team as a %s! -

- -
-

Welcome to the Team!

-

Click the button below to accept your invitation and get started.

-
- - - -

- This invitation will expire in 24 hours. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, staffRole, link) - ); - - TicketReplyTemplate TICKET_REPLY_TEMPLATE = (serverName, playerName, isStaffReply, ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl) -> new HTMLEmail( - "%s | Someone replied to Ticket #%s".formatted(serverName, ticketId), - """ -
-
-

Ticket Reply Notification

- -

Hello %s,

- -

- %s has replied to your %s ticket: -

- -
-

Ticket #%s: %s

-
- -
-
Reply from %s:
-

%s

-
- - - -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(playerName, isStaffReply ? "A staff member" : "Someone", ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl)); - - TicketTranscriptTemplate TICKET_TRANSCRIPT_TEMPLATE = (serverName, playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl) -> new HTMLEmail( - "%s | Ticket #%s Transcript".formatted(serverName, ticketId), - """ -
-
-

Ticket Closed - Transcript

- -

Hello %s,

- -

- Your %s ticket has been closed. Here is the full transcript: -

- -
-

Ticket #%s: %s

-
- - %s - - - -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl)); - - CodeTemplate TICKET_VERIFICATION_CODE = (serverName, code) -> new HTMLEmail( - "%s | Ticket Verification Code".formatted(serverName), - """ -
-
-

Ticket Verification Code

- -

- Your ticket verification code for %s is: -

- -
-

%s

-
- -

- This code will expire in 15 minutes. -

- -
-

- This is an automated message. Please do not reply to this email. -

-
-
-
- """.formatted(serverName, code) - ); - - interface CodeTemplate { - HTMLEmail build(String serverName, String code); - } - - interface VerifyLinkTemplate { - HTMLEmail build(String link); - } - - interface EmailChangedNoticeTemplate { - HTMLEmail build(String serverName, String newEmail); - } - - interface BetaReadyTemplate { - HTMLEmail build(String serverName, String panelLink); - } - - interface StaffInviteTemplate { - HTMLEmail build(String serverName, String staffRole, String link); - } - - interface TicketReplyTemplate { - HTMLEmail build(String serverName, String playerName, boolean isStaffReply, String ticketType, String ticketId, String ticketSubject, String replyAuthor, String replyContent, String ticketUrl); - } - - interface TicketTranscriptTemplate { - HTMLEmail build(String serverName, String playerName, String ticketType, String ticketId, String ticketSubject, String messagesHtml, String ticketUrl); - } - - record HTMLEmail(String subject, String body) { - } -} +package gg.modl.backend.email; + +import static gg.modl.backend.Constants.BRAND_NAME; + +public interface EmailHTMLTemplate { + CodeTemplate USER_CODE = (serverName, code) -> new HTMLEmail( + "%s | Login Code".formatted(serverName), + """ +
+
+

Login Verification Code

+ +

+ Your login verification code for %s is: +

+ +
+

%s

+
+ +

+ This code will expire in 15 minutes. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, code) + ); + + CodeTemplate ADMIN_CODE = (serverName, code) -> new HTMLEmail( + BRAND_NAME + " | Admin Login Code", + """ +
+
+

Admin Verification Code

+ +

+ Your admin panel login code is: +

+ +
+

%s

+
+ +

+ This code will expire in 15 minutes. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(code)); + + CodeTemplate EMAIL_CHANGE_CODE = (serverName, code) -> new HTMLEmail( + "%s | Confirm your new email".formatted(serverName), + """ +
+
+

Confirm Your New Email

+ +

+ Use this code to confirm your new email address for %s: +

+ +
+

%s

+
+ +

+ This code will expire shortly. If you did not request an email change, you can ignore this message and your address will stay the same. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, code) + ); + + EmailChangedNoticeTemplate EMAIL_CHANGED_NOTICE = (serverName, newEmail) -> new HTMLEmail( + "%s | Your email address was changed".formatted(serverName), + """ +
+
+

Email Address Changed

+ +

+ The email address for your %s staff account was just changed to %s. +

+ +

+ If you made this change, no action is needed. If you did not, contact your server administrator immediately. Your account security may be at risk. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, newEmail) + ); + + VerifyLinkTemplate REGISTRATION_VERIFY_LINK = link -> new HTMLEmail( + BRAND_NAME + " | Verify your email", + """ +
+
+

Verify Your Email

+ +

+ Thank you for registering with %s! +

+ +

+ Please verify your email address by clicking the button below: +

+ + + +

+ Or copy and paste this link into your browser: +

+

%s

+ +
+

+ If you didn't create an account, you can safely ignore this email. +

+
+
+
+ """.formatted(BRAND_NAME, link, link)); + + BetaReadyTemplate BETA_PANEL_READY = (serverName, panelLink) -> new HTMLEmail( + "%s | Your beta panel is ready".formatted(serverName), + """ +
+
+

Welcome to the %s Beta

+ +

+ Your beta tester panel for %s is provisioned and ready to use. +

+ +
+

Premium unlocked

+

You've been granted Premium access for free while you help us test. Some usage limits apply during the beta.

+
+ + + +

+ Or copy and paste this link into your browser: +

+

%s

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(BRAND_NAME, serverName, panelLink, panelLink)); + + StaffInviteTemplate STAFF_INVITE_TEMPLATE = (serverName, staffRole, link) -> new HTMLEmail( + "%s | Staff Invitation".formatted(serverName), + """ +
+
+

Team Invitation

+ +

+ You have been invited to join the %s team as a %s! +

+ +
+

Welcome to the Team!

+

Click the button below to accept your invitation and get started.

+
+ + + +

+ This invitation will expire in 24 hours. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, staffRole, link) + ); + + TicketReplyTemplate TICKET_REPLY_TEMPLATE = (serverName, playerName, isStaffReply, ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl) -> new HTMLEmail( + "%s | Someone replied to Ticket #%s".formatted(serverName, ticketId), + """ +
+
+

Ticket Reply Notification

+ +

Hello %s,

+ +

+ %s has replied to your %s ticket: +

+ +
+

Ticket #%s: %s

+
+ +
+
Reply from %s:
+

%s

+
+ + + +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(playerName, isStaffReply ? "A staff member" : "Someone", ticketType, ticketId, ticketSubject, replyAuthor, replyContent, ticketUrl)); + + TicketTranscriptTemplate TICKET_TRANSCRIPT_TEMPLATE = (serverName, playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl) -> new HTMLEmail( + "%s | Ticket #%s Transcript".formatted(serverName, ticketId), + """ +
+
+

Ticket Closed - Transcript

+ +

Hello %s,

+ +

+ Your %s ticket has been closed. Here is the full transcript: +

+ +
+

Ticket #%s: %s

+
+ + %s + + + +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(playerName, ticketType, ticketId, ticketSubject, messagesHtml, ticketUrl)); + + CodeTemplate TICKET_VERIFICATION_CODE = (serverName, code) -> new HTMLEmail( + "%s | Ticket Verification Code".formatted(serverName), + """ +
+
+

Ticket Verification Code

+ +

+ Your ticket verification code for %s is: +

+ +
+

%s

+
+ +

+ This code will expire in 15 minutes. +

+ +
+

+ This is an automated message. Please do not reply to this email. +

+
+
+
+ """.formatted(serverName, code) + ); + + interface CodeTemplate { + HTMLEmail build(String serverName, String code); + } + + interface VerifyLinkTemplate { + HTMLEmail build(String link); + } + + interface EmailChangedNoticeTemplate { + HTMLEmail build(String serverName, String newEmail); + } + + interface BetaReadyTemplate { + HTMLEmail build(String serverName, String panelLink); + } + + interface StaffInviteTemplate { + HTMLEmail build(String serverName, String staffRole, String link); + } + + interface TicketReplyTemplate { + HTMLEmail build(String serverName, String playerName, boolean isStaffReply, String ticketType, String ticketId, String ticketSubject, String replyAuthor, String replyContent, String ticketUrl); + } + + interface TicketTranscriptTemplate { + HTMLEmail build(String serverName, String playerName, String ticketType, String ticketId, String ticketSubject, String messagesHtml, String ticketUrl); + } + + record HTMLEmail(String subject, String body) { + } +} diff --git a/src/main/java/gg/modl/backend/email/EmailService.java b/src/main/java/gg/modl/backend/email/EmailService.java index 9c94a90..6ed268b 100644 --- a/src/main/java/gg/modl/backend/email/EmailService.java +++ b/src/main/java/gg/modl/backend/email/EmailService.java @@ -1,23 +1,23 @@ -package gg.modl.backend.email; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class EmailService { - private final AsyncEmailDispatcher dispatcher; - - public void sendStaffInviteEmail(String toEmail, String serverName, String role, String invitationLink) { - EmailHTMLTemplate.HTMLEmail email = EmailHTMLTemplate.STAFF_INVITE_TEMPLATE.build(serverName, role, invitationLink); - send(toEmail, email); - } - - public void send(String toEmail, EmailHTMLTemplate.HTMLEmail email) { - send(toEmail, email.subject(), email.body()); - } - - public void send(String toEmail, String subject, String htmlBody) { - dispatcher.dispatch(toEmail, subject, htmlBody); - } -} +package gg.modl.backend.email; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class EmailService { + private final AsyncEmailDispatcher dispatcher; + + public void sendStaffInviteEmail(String toEmail, String serverName, String role, String invitationLink) { + EmailHTMLTemplate.HTMLEmail email = EmailHTMLTemplate.STAFF_INVITE_TEMPLATE.build(serverName, role, invitationLink); + send(toEmail, email); + } + + public void send(String toEmail, EmailHTMLTemplate.HTMLEmail email) { + send(toEmail, email.subject(), email.body()); + } + + public void send(String toEmail, String subject, String htmlBody) { + dispatcher.dispatch(toEmail, subject, htmlBody); + } +} diff --git a/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java b/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java index 6a672a2..8396f46 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java +++ b/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java @@ -1,93 +1,93 @@ -package gg.modl.backend.homepage.controller; - -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.homepage.service.HomepageCardService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateCardRequest; -import gg.modl.proto.modl.v1.HomepageCardMutationResponse; -import gg.modl.proto.modl.v1.PanelHomepageCardResponse; -import gg.modl.proto.modl.v1.PanelHomepageCardsResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.ReorderRequest; -import gg.modl.proto.modl.v1.UpdateCardRequest; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS) -@RequiredArgsConstructor -public class PanelHomepageCardController { - private final HomepageCardService cardService; - private final HomepageProtoMapper mapper; - private final RealtimeEventPublisher publisher; - - @GetMapping - public ResponseEntity getCards(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toPanelCardsResponse(cardService.getAllCardsEnriched(server))); - } - - @PostMapping - public ResponseEntity createCard( - @RequestBody CreateCardRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - HomepageCard card = cardService.createCard(server, createRequest); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toPanelCardResponse(card)); - } - - @PutMapping("/{id}") - public ResponseEntity updateCard( - @PathVariable String id, - @RequestBody UpdateCardRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return cardService.updateCard(server, id, updateRequest) - .map(card -> { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); - return ResponseEntity.ok(mapper.toPanelCardResponse(card)); - }) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteCard( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean deleted = cardService.deleteCard(server, id); - if (deleted) { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, id); - return ResponseEntity.ok(mapper.message("Card deleted")); - } - return ResponseEntity.notFound().build(); - } - - @PutMapping("/reorder") - public ResponseEntity reorderCards( - @RequestBody ReorderRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - cardService.reorderCards(server, reorderRequest.getIdsList()); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE); - return ResponseEntity.ok(mapper.message("Cards reordered")); - } -} +package gg.modl.backend.homepage.controller; + +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateCardRequest; +import gg.modl.proto.modl.v1.HomepageCardMutationResponse; +import gg.modl.proto.modl.v1.PanelHomepageCardResponse; +import gg.modl.proto.modl.v1.PanelHomepageCardsResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.ReorderRequest; +import gg.modl.proto.modl.v1.UpdateCardRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS) +@RequiredArgsConstructor +public class PanelHomepageCardController { + private final HomepageCardService cardService; + private final HomepageProtoMapper mapper; + private final RealtimeEventPublisher publisher; + + @GetMapping + public ResponseEntity getCards(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toPanelCardsResponse(cardService.getAllCardsEnriched(server))); + } + + @PostMapping + public ResponseEntity createCard( + @RequestBody CreateCardRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + HomepageCard card = cardService.createCard(server, createRequest); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toPanelCardResponse(card)); + } + + @PutMapping("/{id}") + public ResponseEntity updateCard( + @PathVariable String id, + @RequestBody UpdateCardRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return cardService.updateCard(server, id, updateRequest) + .map(card -> { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, card.getId()); + return ResponseEntity.ok(mapper.toPanelCardResponse(card)); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteCard( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean deleted = cardService.deleteCard(server, id); + if (deleted) { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE, id); + return ResponseEntity.ok(mapper.message("Card deleted")); + } + return ResponseEntity.notFound().build(); + } + + @PutMapping("/reorder") + public ResponseEntity reorderCards( + @RequestBody ReorderRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + cardService.reorderCards(server, reorderRequest.getIdsList()); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_HOMEPAGE); + return ResponseEntity.ok(mapper.message("Cards reordered")); + } +} diff --git a/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java b/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java index 06e6662..6dd0f15 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java +++ b/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java @@ -1,36 +1,36 @@ -package gg.modl.backend.homepage.controller; - -import gg.modl.backend.homepage.dto.response.HomepageCardResponse; -import gg.modl.backend.homepage.service.HomepageCardService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.PublicHomepageCardsResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -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; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_HOMEPAGE_CARDS) -@RequiredArgsConstructor -public class PublicHomepageCardController { - private final HomepageCardService cardService; - private final HomepageProtoMapper mapper; - - @GetMapping - public ResponseEntity getCards(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List enrichedCards = cardService.getVisibleCardsEnrichedWithArticles(server).stream() - .map(enriched -> enriched.category() == null - ? HomepageCardResponse.from(enriched.card()) - : HomepageCardResponse.from(enriched.card(), - HomepageCardResponse.EmbeddedCategory.from(enriched.category(), enriched.articles()))) - .toList(); - - return ResponseEntity.ok(mapper.toPublicCardsResponse(enrichedCards)); - } -} +package gg.modl.backend.homepage.controller; + +import gg.modl.backend.homepage.dto.response.HomepageCardResponse; +import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.PublicHomepageCardsResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_HOMEPAGE_CARDS) +@RequiredArgsConstructor +public class PublicHomepageCardController { + private final HomepageCardService cardService; + private final HomepageProtoMapper mapper; + + @GetMapping + public ResponseEntity getCards(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List enrichedCards = cardService.getVisibleCardsEnrichedWithArticles(server).stream() + .map(enriched -> enriched.category() == null + ? HomepageCardResponse.from(enriched.card()) + : HomepageCardResponse.from(enriched.card(), + HomepageCardResponse.EmbeddedCategory.from(enriched.category(), enriched.articles()))) + .toList(); + + return ResponseEntity.ok(mapper.toPublicCardsResponse(enrichedCards)); + } +} diff --git a/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java b/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java index e99992e..b9cf022 100644 --- a/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java +++ b/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java @@ -1,77 +1,77 @@ -package gg.modl.backend.homepage.dto.response; - -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; -import java.util.Date; -import java.util.List; - -public record HomepageCardResponse( - String id, - String title, - String description, - String icon, - String iconColor, - String actionType, - String actionUrl, - String actionButtonText, - String categoryId, - String backgroundColor, - int ordinal, - boolean isEnabled, - Date createdAt, - Date updatedAt, - EmbeddedCategory category -) { - public static HomepageCardResponse from(HomepageCard card) { - return from(card, null); - } - - public static HomepageCardResponse from(HomepageCard card, EmbeddedCategory category) { - return new HomepageCardResponse( - card.getId(), - card.getTitle(), - card.getDescription(), - card.getIcon(), - card.getIconColor(), - card.getActionType(), - card.getActionUrl(), - card.getActionButtonText(), - card.getCategoryId(), - card.getBackgroundColor(), - card.getOrdinal(), - card.isEnabled(), - card.getCreatedAt(), - card.getUpdatedAt(), - category - ); - } - - public record EmbeddedCategory( - String id, - String name, - String slug, - String description, - List articles - ) { - public static EmbeddedCategory from(KnowledgebaseCategory category, List articles) { - List articleStubs = articles.stream() - .map(a -> new ArticleStub(a.getId(), a.getTitle(), a.getSlug(), a.getOrdinal())) - .toList(); - return new EmbeddedCategory( - category.getId(), - category.getName(), - category.getSlug(), - category.getDescription(), - articleStubs - ); - } - } - - public record ArticleStub( - String id, - String title, - String slug, - int ordinal - ) {} -} +package gg.modl.backend.homepage.dto.response; + +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; +import java.util.Date; +import java.util.List; + +public record HomepageCardResponse( + String id, + String title, + String description, + String icon, + String iconColor, + String actionType, + String actionUrl, + String actionButtonText, + String categoryId, + String backgroundColor, + int ordinal, + boolean isEnabled, + Date createdAt, + Date updatedAt, + EmbeddedCategory category +) { + public static HomepageCardResponse from(HomepageCard card) { + return from(card, null); + } + + public static HomepageCardResponse from(HomepageCard card, EmbeddedCategory category) { + return new HomepageCardResponse( + card.getId(), + card.getTitle(), + card.getDescription(), + card.getIcon(), + card.getIconColor(), + card.getActionType(), + card.getActionUrl(), + card.getActionButtonText(), + card.getCategoryId(), + card.getBackgroundColor(), + card.getOrdinal(), + card.isEnabled(), + card.getCreatedAt(), + card.getUpdatedAt(), + category + ); + } + + public record EmbeddedCategory( + String id, + String name, + String slug, + String description, + List articles + ) { + public static EmbeddedCategory from(KnowledgebaseCategory category, List articles) { + List articleStubs = articles.stream() + .map(a -> new ArticleStub(a.getId(), a.getTitle(), a.getSlug(), a.getOrdinal())) + .toList(); + return new EmbeddedCategory( + category.getId(), + category.getName(), + category.getSlug(), + category.getDescription(), + articleStubs + ); + } + } + + public record ArticleStub( + String id, + String title, + String slug, + int ordinal + ) {} +} diff --git a/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java b/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java index 990b4d0..3ba8c72 100644 --- a/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java +++ b/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java @@ -1,164 +1,164 @@ -package gg.modl.backend.homepage.service; - -import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; -import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; -import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; -import gg.modl.backend.infrastructure.validation.SafeUrls; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateCardRequest; -import gg.modl.proto.modl.v1.UpdateCardRequest; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class HomepageCardService { - private final HomepageCardMongoRepository homepageCardRepository; - private final KnowledgebaseCategoryService categoryService; - private final KnowledgebaseArticleService articleService; - - public List getVisibleCards(Server server) { - return homepageCardRepository.findVisibleOrdered(server); - } - - public List getVisibleCardsEnrichedWithArticles(Server server) { - List cards = getVisibleCards(server); - - List categoryIds = cards.stream() - .filter(card -> "category_dropdown".equals(card.getActionType())) - .map(HomepageCard::getCategoryId) - .filter(id -> id != null && !id.isEmpty()) - .distinct() - .toList(); - - Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) - .stream() - .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); - - Map> articlesByCategoryId = - articleService.getVisibleArticlesGroupedByCategoryIds(server, categoryIds); - - return cards.stream() - .map(card -> { - KnowledgebaseCategory category = null; - if ("category_dropdown".equals(card.getActionType()) - && card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { - category = categoriesById.get(card.getCategoryId()); - } - List articles = category != null - ? articlesByCategoryId.getOrDefault(category.getId(), List.of()) - : List.of(); - return new EnrichedCardWithArticles(card, category, articles); - }) - .toList(); - } - - public Optional getCardById(Server server, String id) { - return homepageCardRepository.findByCardId(server, id); - } - - public HomepageCard createCard(Server server, CreateCardRequest request) { - String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; - SafeUrls.requireSafe(actionUrl, "Invalid card URL"); - HomepageCard card = HomepageCard.builder() - .title(request.getTitle()) - .description(request.hasDescription() ? request.getDescription() : null) - .icon(request.hasIcon() ? request.getIcon() : null) - .iconColor(request.hasIconColor() ? request.getIconColor() : null) - .actionType(request.hasActionType() ? request.getActionType() : null) - .actionUrl(actionUrl) - .actionButtonText(request.hasActionButtonText() ? request.getActionButtonText() : null) - .categoryId(request.hasCategoryId() ? request.getCategoryId() : null) - .backgroundColor(request.hasBackgroundColor() ? request.getBackgroundColor() : null) - .ordinal(homepageCardRepository.findMaxOrdinal(server) + 1) - .isEnabled(!request.hasIsEnabled() || request.getIsEnabled()) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - return homepageCardRepository.saveEntity(server, card); - } - - public Optional updateCard(Server server, String id, UpdateCardRequest request) { - String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; - SafeUrls.requireSafe(actionUrl, "Invalid card URL"); - return homepageCardRepository.updateCard( - server, - id, - request.hasTitle() ? request.getTitle() : null, - request.hasDescription() ? request.getDescription() : null, - request.hasIcon() ? request.getIcon() : null, - request.hasIconColor() ? request.getIconColor() : null, - request.hasActionType() ? request.getActionType() : null, - actionUrl, - request.hasActionButtonText() ? request.getActionButtonText() : null, - request.hasCategoryId() ? request.getCategoryId() : null, - request.hasBackgroundColor() ? request.getBackgroundColor() : null, - request.hasIsEnabled() ? request.getIsEnabled() : null, - new Date() - ); - } - - public boolean deleteCard(Server server, String id) { - return homepageCardRepository.deleteByCardId(server, id); - } - - public void reorderCards(Server server, List ids) { - homepageCardRepository.reorderCards(server, ids); - } - - public List getAllCardsEnriched(Server server) { - List cards = getAllCards(server); - - List categoryIds = cards.stream() - .map(HomepageCard::getCategoryId) - .filter(id -> id != null && !id.isEmpty()) - .distinct() - .toList(); - - Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) - .stream() - .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); - - return cards.stream() - .map(card -> { - EmbeddedCategory embedded = null; - if (card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { - KnowledgebaseCategory cat = categoriesById.get(card.getCategoryId()); - if (cat != null) { - embedded = new EmbeddedCategory(cat.getId(), cat.getName(), cat.getSlug()); - } - } - return new EnrichedCard(card, embedded); - }) - .toList(); - } - - public List getAllCards(Server server) { - return homepageCardRepository.findAllOrdered(server); - } - - public record EmbeddedCategory(String id, String name, String slug) {} - - public record EnrichedCard( - HomepageCard card, - EmbeddedCategory category - ) {} - - public record EnrichedCardWithArticles( - HomepageCard card, - KnowledgebaseCategory category, - List articles - ) {} -} +package gg.modl.backend.homepage.service; + +import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; +import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; +import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; +import gg.modl.backend.infrastructure.validation.SafeUrls; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateCardRequest; +import gg.modl.proto.modl.v1.UpdateCardRequest; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class HomepageCardService { + private final HomepageCardMongoRepository homepageCardRepository; + private final KnowledgebaseCategoryService categoryService; + private final KnowledgebaseArticleService articleService; + + public List getVisibleCards(Server server) { + return homepageCardRepository.findVisibleOrdered(server); + } + + public List getVisibleCardsEnrichedWithArticles(Server server) { + List cards = getVisibleCards(server); + + List categoryIds = cards.stream() + .filter(card -> "category_dropdown".equals(card.getActionType())) + .map(HomepageCard::getCategoryId) + .filter(id -> id != null && !id.isEmpty()) + .distinct() + .toList(); + + Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) + .stream() + .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); + + Map> articlesByCategoryId = + articleService.getVisibleArticlesGroupedByCategoryIds(server, categoryIds); + + return cards.stream() + .map(card -> { + KnowledgebaseCategory category = null; + if ("category_dropdown".equals(card.getActionType()) + && card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { + category = categoriesById.get(card.getCategoryId()); + } + List articles = category != null + ? articlesByCategoryId.getOrDefault(category.getId(), List.of()) + : List.of(); + return new EnrichedCardWithArticles(card, category, articles); + }) + .toList(); + } + + public Optional getCardById(Server server, String id) { + return homepageCardRepository.findByCardId(server, id); + } + + public HomepageCard createCard(Server server, CreateCardRequest request) { + String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; + SafeUrls.requireSafe(actionUrl, "Invalid card URL"); + HomepageCard card = HomepageCard.builder() + .title(request.getTitle()) + .description(request.hasDescription() ? request.getDescription() : null) + .icon(request.hasIcon() ? request.getIcon() : null) + .iconColor(request.hasIconColor() ? request.getIconColor() : null) + .actionType(request.hasActionType() ? request.getActionType() : null) + .actionUrl(actionUrl) + .actionButtonText(request.hasActionButtonText() ? request.getActionButtonText() : null) + .categoryId(request.hasCategoryId() ? request.getCategoryId() : null) + .backgroundColor(request.hasBackgroundColor() ? request.getBackgroundColor() : null) + .ordinal(homepageCardRepository.findMaxOrdinal(server) + 1) + .isEnabled(!request.hasIsEnabled() || request.getIsEnabled()) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + return homepageCardRepository.saveEntity(server, card); + } + + public Optional updateCard(Server server, String id, UpdateCardRequest request) { + String actionUrl = request.hasActionUrl() ? request.getActionUrl() : null; + SafeUrls.requireSafe(actionUrl, "Invalid card URL"); + return homepageCardRepository.updateCard( + server, + id, + request.hasTitle() ? request.getTitle() : null, + request.hasDescription() ? request.getDescription() : null, + request.hasIcon() ? request.getIcon() : null, + request.hasIconColor() ? request.getIconColor() : null, + request.hasActionType() ? request.getActionType() : null, + actionUrl, + request.hasActionButtonText() ? request.getActionButtonText() : null, + request.hasCategoryId() ? request.getCategoryId() : null, + request.hasBackgroundColor() ? request.getBackgroundColor() : null, + request.hasIsEnabled() ? request.getIsEnabled() : null, + new Date() + ); + } + + public boolean deleteCard(Server server, String id) { + return homepageCardRepository.deleteByCardId(server, id); + } + + public void reorderCards(Server server, List ids) { + homepageCardRepository.reorderCards(server, ids); + } + + public List getAllCardsEnriched(Server server) { + List cards = getAllCards(server); + + List categoryIds = cards.stream() + .map(HomepageCard::getCategoryId) + .filter(id -> id != null && !id.isEmpty()) + .distinct() + .toList(); + + Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) + .stream() + .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); + + return cards.stream() + .map(card -> { + EmbeddedCategory embedded = null; + if (card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { + KnowledgebaseCategory cat = categoriesById.get(card.getCategoryId()); + if (cat != null) { + embedded = new EmbeddedCategory(cat.getId(), cat.getName(), cat.getSlug()); + } + } + return new EnrichedCard(card, embedded); + }) + .toList(); + } + + public List getAllCards(Server server) { + return homepageCardRepository.findAllOrdered(server); + } + + public record EmbeddedCategory(String id, String name, String slug) {} + + public record EnrichedCard( + HomepageCard card, + EmbeddedCategory category + ) {} + + public record EnrichedCardWithArticles( + HomepageCard card, + KnowledgebaseCategory category, + List articles + ) {} +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java b/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java index 48732a1..51854cc 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/AsyncConfig.java @@ -1,43 +1,43 @@ -package gg.modl.backend.infrastructure.config; - -import java.util.concurrent.Executors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.task.AsyncTaskExecutor; -import org.springframework.core.task.support.TaskExecutorAdapter; -import org.springframework.scheduling.annotation.EnableAsync; -import org.springframework.scheduling.annotation.EnableScheduling; -import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; - -@Configuration -@EnableAsync -@EnableScheduling -@Slf4j -public class AsyncConfig { - - @Bean - public AsyncTaskExecutor applicationTaskExecutor() { - return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); - } - - @Bean(autowireCandidate = false) - public ThreadPoolTaskExecutor emailTaskExecutor( - @Value("${modl.email.executor.core-pool-size:2}") int corePoolSize, - @Value("${modl.email.executor.max-pool-size:8}") int maxPoolSize, - @Value("${modl.email.executor.queue-capacity:500}") int queueCapacity, - @Value("${modl.email.executor.await-termination-seconds:20}") int awaitTerminationSeconds - ) { - ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(corePoolSize); - executor.setMaxPoolSize(maxPoolSize); - executor.setQueueCapacity(queueCapacity); - executor.setThreadNamePrefix("email-"); - executor.setRejectedExecutionHandler((task, rejectingExecutor) -> - log.warn("Email executor saturated; dropping queued email send")); - executor.setWaitForTasksToCompleteOnShutdown(true); - executor.setAwaitTerminationSeconds(awaitTerminationSeconds); - return executor; - } -} +package gg.modl.backend.infrastructure.config; + +import java.util.concurrent.Executors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.support.TaskExecutorAdapter; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +@EnableScheduling +@Slf4j +public class AsyncConfig { + + @Bean + public AsyncTaskExecutor applicationTaskExecutor() { + return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor()); + } + + @Bean(autowireCandidate = false) + public ThreadPoolTaskExecutor emailTaskExecutor( + @Value("${modl.email.executor.core-pool-size:2}") int corePoolSize, + @Value("${modl.email.executor.max-pool-size:8}") int maxPoolSize, + @Value("${modl.email.executor.queue-capacity:500}") int queueCapacity, + @Value("${modl.email.executor.await-termination-seconds:20}") int awaitTerminationSeconds + ) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("email-"); + executor.setRejectedExecutionHandler((task, rejectingExecutor) -> + log.warn("Email executor saturated; dropping queued email send")); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(awaitTerminationSeconds); + return executor; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java b/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java index 470af10..656029f 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java @@ -1,45 +1,45 @@ -package gg.modl.backend.infrastructure.config; - -import jakarta.annotation.PostConstruct; -import java.util.Arrays; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.env.Environment; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Slf4j -public class DevelopmentModeGuard { - private final Environment environment; - private final ModlProperties modlProperties; - private static final Set DEV_PROFILES = Set.of("dev", "local", "test"); - - @PostConstruct - public void validate() { - if (!modlProperties.isDevelopmentMode()) { - return; - } - - boolean isDevProfile = Arrays.stream(environment.getActiveProfiles()) - .anyMatch(profile -> DEV_PROFILES.contains(profile.toLowerCase())); - boolean explicitlyAllowed = environment.getProperty("MODL_ALLOW_DEV_MODE", Boolean.class, false); - - if (!isDevProfile && !explicitlyAllowed) { - throw new IllegalStateException( - "FATAL: modl.development-mode=true is set but no development context was detected. " + - "Development mode disables CSRF protection, captcha validation, and weakens cookie security, " + - "and must never run in production. To run development mode, activate a dev/local/test Spring " + - "profile (e.g. SPRING_PROFILES_ACTIVE=dev) or set MODL_ALLOW_DEV_MODE=true. For production, " + - "remove modl.development-mode or set it to false." - ); - } - - log.warn("======================================================================"); - log.warn(" DEVELOPMENT MODE IS ENABLED"); - log.warn(" CSRF protection, captcha validation, and cookie security are relaxed."); - log.warn(" Do NOT use this setting in production."); - log.warn("======================================================================"); - } -} +package gg.modl.backend.infrastructure.config; + +import jakarta.annotation.PostConstruct; +import java.util.Arrays; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class DevelopmentModeGuard { + private final Environment environment; + private final ModlProperties modlProperties; + private static final Set DEV_PROFILES = Set.of("dev", "local", "test"); + + @PostConstruct + public void validate() { + if (!modlProperties.isDevelopmentMode()) { + return; + } + + boolean isDevProfile = Arrays.stream(environment.getActiveProfiles()) + .anyMatch(profile -> DEV_PROFILES.contains(profile.toLowerCase())); + boolean explicitlyAllowed = environment.getProperty("MODL_ALLOW_DEV_MODE", Boolean.class, false); + + if (!isDevProfile && !explicitlyAllowed) { + throw new IllegalStateException( + "FATAL: modl.development-mode=true is set but no development context was detected. " + + "Development mode disables CSRF protection, captcha validation, and weakens cookie security, " + + "and must never run in production. To run development mode, activate a dev/local/test Spring " + + "profile (e.g. SPRING_PROFILES_ACTIVE=dev) or set MODL_ALLOW_DEV_MODE=true. For production, " + + "remove modl.development-mode or set it to false." + ); + } + + log.warn("======================================================================"); + log.warn(" DEVELOPMENT MODE IS ENABLED"); + log.warn(" CSRF protection, captcha validation, and cookie security are relaxed."); + log.warn(" Do NOT use this setting in production."); + log.warn("======================================================================"); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java b/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java index d332d2c..41e6e05 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/ModlCorsProperties.java @@ -1,19 +1,19 @@ -package gg.modl.backend.infrastructure.config; - -import jakarta.validation.constraints.NotNull; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@ConfigurationProperties(prefix = "modl.cors") -public class ModlCorsProperties { - @NotNull - private String systemOrigins = "https://modl.gg,https://admin.modl.gg,https://modl.top,https://admin.modl.top"; - +package gg.modl.backend.infrastructure.config; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl.cors") +public class ModlCorsProperties { + @NotNull + private String systemOrigins = "https://modl.gg,https://admin.modl.gg,https://modl.top,https://admin.modl.top"; + @NotNull private String appDomains = "modl.gg,modl.top"; diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java b/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java index 39f790a..df236c0 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/ModlDevProperties.java @@ -1,17 +1,17 @@ -package gg.modl.backend.infrastructure.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@ConfigurationProperties(prefix = "modl.dev") -public class ModlDevProperties { - private String serverDomain = ""; - private String serverName = "Local Test"; - private String seedAdminEmail = "admin@localtest.dev"; - private String seedApiKey = ""; -} +package gg.modl.backend.infrastructure.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl.dev") +public class ModlDevProperties { + private String serverDomain = ""; + private String serverName = "Local Test"; + private String seedAdminEmail = "admin@localtest.dev"; + private String seedApiKey = ""; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java b/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java index 42a92a3..872b51a 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/ModlProperties.java @@ -1,18 +1,18 @@ -package gg.modl.backend.infrastructure.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@ConfigurationProperties(prefix = "modl") -public class ModlProperties { - private boolean developmentMode; - - private String domain = "modl.gg"; - - private String appDomain; -} +package gg.modl.backend.infrastructure.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "modl") +public class ModlProperties { + private boolean developmentMode; + + private String domain = "modl.gg"; + + private String appDomain; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java b/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java index ff8762a..01bbdd6 100644 --- a/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java +++ b/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java @@ -1,154 +1,154 @@ -package gg.modl.backend.infrastructure.cors; - -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.infrastructure.origin.OriginPolicy; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.server.ServerService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.util.HostExtractionUtil; -import jakarta.annotation.PostConstruct; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.CorsConfigurationSource; - -@Component -@RequiredArgsConstructor -public class DynamicCorsConfigurationSource implements CorsConfigurationSource { - private final ServerService serverService; - private final ModlCorsProperties corsProperties; - private final ModlProperties modlProperties; - private final Map originCache = Collections.synchronizedMap( - new LinkedHashMap<>(64, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_CACHE_SIZE; - } - } - ); - private volatile OriginPolicy originPolicy = new OriginPolicy(Set.of(), Set.of(), false); - private volatile Set parsedReplayLiteOrigins = Set.of(); - private static final int MAX_CACHE_SIZE = 10_000; - private static final long CACHE_TTL_MS = 5 * 60 * 1000; - - @Override - public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { - String origin = request.getHeader("Origin"); - if (origin == null) { - return null; - } - - String path = request.getRequestURI(); - boolean adminPath = isAdminPath(path); - - if (adminPath && !originPolicy.isSystemOrigin(origin)) { - return null; - } - - if (!isOriginAllowed(path, origin)) { - return null; - } - - CorsConfiguration config = new CorsConfiguration(); - config.addAllowedOrigin(origin); - config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("Content-Type", "X-Server-Domain", "X-API-Key", "Cookie", "Accept", "Origin", "Authorization")); - config.setExposedHeaders(List.of( - "X-RateLimit-Remaining", - "X-RateLimit-Retry-After", - "X-Rate-Limit-Remaining", - "X-Rate-Limit-Retry-After-Seconds" - )); - config.setAllowCredentials(true); - config.setMaxAge(3600L); - return config; - } - - private boolean isAdminPath(String path) { - return path != null && ( - path.startsWith(RESTMappingV1.PREFIX_ADMIN + "/") - || path.equals(RESTMappingV1.PREFIX_ADMIN) - ); - } - - private boolean isReplayLitePath(String path) { - return path != null && ( - path.startsWith(RESTMappingV1.PREFIX_REPLAY_LITE + "/") - || path.startsWith(RESTMappingV1.PREFIX_PUBLIC + "/replay-lite/") - ); - } - - private boolean isOriginAllowed(String path, String origin) { - String cacheKey = (isReplayLitePath(path) ? "replay-lite" : "default") + ":" + origin; - CachedOrigin cached = originCache.get(cacheKey); - if (cached != null && !cached.isExpired()) { - return cached.allowed; - } - - boolean allowed = checkOriginAllowed(path, origin); - originCache.put(cacheKey, new CachedOrigin(allowed, System.currentTimeMillis() + CACHE_TTL_MS)); - return allowed; - } - - private boolean checkOriginAllowed(String path, String origin) { - if (isReplayLitePath(path)) { - return parsedReplayLiteOrigins.contains(origin); - } - - if (originPolicy.isSystemOrigin(origin)) { - return true; - } - - String host = HostExtractionUtil.extractHost(origin); - if (host == null) { - return false; - } - - if (originPolicy.isAppDomainOrSubdomain(host)) { - return true; - } - - Server server = serverService.getServerFromDomain(host); - return server != null; - } - - @PostConstruct - void initParsedOrigins() { - originPolicy = new OriginPolicy( - HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), - HostExtractionUtil.parseCommaSeparated(corsProperties.getAppDomains()), - modlProperties.isDevelopmentMode() - ); - parsedReplayLiteOrigins = HostExtractionUtil.parseCommaSeparated(corsProperties.getReplayLiteOrigins()); - } - - public void invalidateCache(String domain) { - originCache.entrySet().removeIf(entry -> { - String host = HostExtractionUtil.extractHost(originFromCacheKey(entry.getKey())); - return domain.equals(host); - }); - } - - public void invalidateCacheForOrigin(String origin) { - originCache.remove("default:" + origin); - originCache.remove("replay-lite:" + origin); - } - - private String originFromCacheKey(String key) { - int separator = key.indexOf(':'); - return separator >= 0 ? key.substring(separator + 1) : key; - } - - private record CachedOrigin(boolean allowed, long expiresAt) { - boolean isExpired() { - return System.currentTimeMillis() > expiresAt; - } - } -} +package gg.modl.backend.infrastructure.cors; + +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.infrastructure.origin.OriginPolicy; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.util.HostExtractionUtil; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + +@Component +@RequiredArgsConstructor +public class DynamicCorsConfigurationSource implements CorsConfigurationSource { + private final ServerService serverService; + private final ModlCorsProperties corsProperties; + private final ModlProperties modlProperties; + private final Map originCache = Collections.synchronizedMap( + new LinkedHashMap<>(64, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + } + ); + private volatile OriginPolicy originPolicy = new OriginPolicy(Set.of(), Set.of(), false); + private volatile Set parsedReplayLiteOrigins = Set.of(); + private static final int MAX_CACHE_SIZE = 10_000; + private static final long CACHE_TTL_MS = 5 * 60 * 1000; + + @Override + public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { + String origin = request.getHeader("Origin"); + if (origin == null) { + return null; + } + + String path = request.getRequestURI(); + boolean adminPath = isAdminPath(path); + + if (adminPath && !originPolicy.isSystemOrigin(origin)) { + return null; + } + + if (!isOriginAllowed(path, origin)) { + return null; + } + + CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin(origin); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Content-Type", "X-Server-Domain", "X-API-Key", "Cookie", "Accept", "Origin", "Authorization")); + config.setExposedHeaders(List.of( + "X-RateLimit-Remaining", + "X-RateLimit-Retry-After", + "X-Rate-Limit-Remaining", + "X-Rate-Limit-Retry-After-Seconds" + )); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + return config; + } + + private boolean isAdminPath(String path) { + return path != null && ( + path.startsWith(RESTMappingV1.PREFIX_ADMIN + "/") + || path.equals(RESTMappingV1.PREFIX_ADMIN) + ); + } + + private boolean isReplayLitePath(String path) { + return path != null && ( + path.startsWith(RESTMappingV1.PREFIX_REPLAY_LITE + "/") + || path.startsWith(RESTMappingV1.PREFIX_PUBLIC + "/replay-lite/") + ); + } + + private boolean isOriginAllowed(String path, String origin) { + String cacheKey = (isReplayLitePath(path) ? "replay-lite" : "default") + ":" + origin; + CachedOrigin cached = originCache.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return cached.allowed; + } + + boolean allowed = checkOriginAllowed(path, origin); + originCache.put(cacheKey, new CachedOrigin(allowed, System.currentTimeMillis() + CACHE_TTL_MS)); + return allowed; + } + + private boolean checkOriginAllowed(String path, String origin) { + if (isReplayLitePath(path)) { + return parsedReplayLiteOrigins.contains(origin); + } + + if (originPolicy.isSystemOrigin(origin)) { + return true; + } + + String host = HostExtractionUtil.extractHost(origin); + if (host == null) { + return false; + } + + if (originPolicy.isAppDomainOrSubdomain(host)) { + return true; + } + + Server server = serverService.getServerFromDomain(host); + return server != null; + } + + @PostConstruct + void initParsedOrigins() { + originPolicy = new OriginPolicy( + HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), + HostExtractionUtil.parseCommaSeparated(corsProperties.getAppDomains()), + modlProperties.isDevelopmentMode() + ); + parsedReplayLiteOrigins = HostExtractionUtil.parseCommaSeparated(corsProperties.getReplayLiteOrigins()); + } + + public void invalidateCache(String domain) { + originCache.entrySet().removeIf(entry -> { + String host = HostExtractionUtil.extractHost(originFromCacheKey(entry.getKey())); + return domain.equals(host); + }); + } + + public void invalidateCacheForOrigin(String origin) { + originCache.remove("default:" + origin); + originCache.remove("replay-lite:" + origin); + } + + private String originFromCacheKey(String key) { + int separator = key.indexOf(':'); + return separator >= 0 ? key.substring(separator + 1) : key; + } + + private record CachedOrigin(boolean allowed, long expiresAt) { + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java b/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java index ad51039..4a32d23 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/BaseApplicationException.java @@ -1,19 +1,19 @@ -package gg.modl.backend.infrastructure.exception; - -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -public abstract class BaseApplicationException extends RuntimeException { - private final HttpStatus status; - - protected BaseApplicationException(String message, HttpStatus status) { - super(message); - this.status = status; - } - - protected BaseApplicationException(String message, HttpStatus status, Throwable cause) { - super(message, cause); - this.status = status; - } -} +package gg.modl.backend.infrastructure.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public abstract class BaseApplicationException extends RuntimeException { + private final HttpStatus status; + + protected BaseApplicationException(String message, HttpStatus status) { + super(message); + this.status = status; + } + + protected BaseApplicationException(String message, HttpStatus status, Throwable cause) { + super(message, cause); + this.status = status; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java index 9b6eae5..7ea12df 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ConflictException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ConflictException extends BaseApplicationException { - public ConflictException(String message) { - super(message, HttpStatus.CONFLICT); - } - - public ConflictException(String message, Throwable cause) { - super(message, HttpStatus.CONFLICT, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ConflictException extends BaseApplicationException { + public ConflictException(String message) { + super(message, HttpStatus.CONFLICT); + } + + public ConflictException(String message, Throwable cause) { + super(message, HttpStatus.CONFLICT, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java b/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java index 5d9f892..11310ec 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ErrorResponseDTO.java @@ -1,7 +1,7 @@ -package gg.modl.backend.infrastructure.exception; - -public record ErrorResponseDTO(int status, String error, String message) { - public ErrorResponseDTO(int status, String error) { - this(status, error, error); - } -} +package gg.modl.backend.infrastructure.exception; + +public record ErrorResponseDTO(int status, String error, String message) { + public ErrorResponseDTO(int status, String error) { + this(status, error, error); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java index 21c7390..00fb824 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ExternalServiceException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ExternalServiceException extends BaseApplicationException { - public ExternalServiceException(String message) { - super(message, HttpStatus.BAD_GATEWAY); - } - - public ExternalServiceException(String message, Throwable cause) { - super(message, HttpStatus.BAD_GATEWAY, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ExternalServiceException extends BaseApplicationException { + public ExternalServiceException(String message) { + super(message, HttpStatus.BAD_GATEWAY); + } + + public ExternalServiceException(String message, Throwable cause) { + super(message, HttpStatus.BAD_GATEWAY, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java index b9f11a0..67c78bc 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ForbiddenException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ForbiddenException extends BaseApplicationException { - public ForbiddenException(String message) { - super(message, HttpStatus.FORBIDDEN); - } - - public ForbiddenException(String message, Throwable cause) { - super(message, HttpStatus.FORBIDDEN, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ForbiddenException extends BaseApplicationException { + public ForbiddenException(String message) { + super(message, HttpStatus.FORBIDDEN); + } + + public ForbiddenException(String message, Throwable cause) { + super(message, HttpStatus.FORBIDDEN, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java index 50d4262..cbeb25e 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ResourceNotFoundException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ResourceNotFoundException extends BaseApplicationException { - public ResourceNotFoundException(String message) { - super(message, HttpStatus.NOT_FOUND); - } - - public ResourceNotFoundException(String message, Throwable cause) { - super(message, HttpStatus.NOT_FOUND, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ResourceNotFoundException extends BaseApplicationException { + public ResourceNotFoundException(String message) { + super(message, HttpStatus.NOT_FOUND); + } + + public ResourceNotFoundException(String message, Throwable cause) { + super(message, HttpStatus.NOT_FOUND, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java b/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java index 20bc036..1a7428c 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/UnauthorizedException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class UnauthorizedException extends BaseApplicationException { - public UnauthorizedException(String message) { - super(message, HttpStatus.UNAUTHORIZED); - } - - public UnauthorizedException(String message, Throwable cause) { - super(message, HttpStatus.UNAUTHORIZED, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class UnauthorizedException extends BaseApplicationException { + public UnauthorizedException(String message) { + super(message, HttpStatus.UNAUTHORIZED); + } + + public UnauthorizedException(String message, Throwable cause) { + super(message, HttpStatus.UNAUTHORIZED, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java b/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java index 65746dc..9033e38 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/ValidationException.java @@ -1,13 +1,13 @@ -package gg.modl.backend.infrastructure.exception; - -import org.springframework.http.HttpStatus; - -public class ValidationException extends BaseApplicationException { - public ValidationException(String message) { - super(message, HttpStatus.BAD_REQUEST); - } - - public ValidationException(String message, Throwable cause) { - super(message, HttpStatus.BAD_REQUEST, cause); - } -} +package gg.modl.backend.infrastructure.exception; + +import org.springframework.http.HttpStatus; + +public class ValidationException extends BaseApplicationException { + public ValidationException(String message) { + super(message, HttpStatus.BAD_REQUEST); + } + + public ValidationException(String message, Throwable cause) { + super(message, HttpStatus.BAD_REQUEST, cause); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java index 76c8058..5fe591f 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java @@ -1,59 +1,59 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.admin.service.AdminAuthService; -import gg.modl.backend.infrastructure.rest.RESTSecurityRole; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -@RequiredArgsConstructor -public class AdminAuthFilter extends OncePerRequestFilter { - private final AdminAuthService adminAuthService; - public static final String ADMIN_SESSION_ATTR = "adminSession"; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - - String path = request.getRequestURI(); - - if (path.startsWith("/v1/admin/") && !path.startsWith("/v1/admin/auth/")) { - Optional sessionOpt = adminAuthService.getAuthenticatedSession(request); - if (sessionOpt.isEmpty()) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json"); - response.getWriter().write("{\"success\":false,\"message\":\"Unauthorized\"}"); - return; - } - - AdminAuthService.AdminSession session = sessionOpt.get(); - request.setAttribute(ADMIN_SESSION_ATTR, session); - - List authorities = List.of( - new SimpleGrantedAuthority(RESTSecurityRole.ADMIN) - ); - - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken( - session.email(), - null, - authorities - ); - authentication.setDetails(session); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - - filterChain.doFilter(request, response); - } -} +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.admin.service.AdminAuthService; +import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class AdminAuthFilter extends OncePerRequestFilter { + private final AdminAuthService adminAuthService; + public static final String ADMIN_SESSION_ATTR = "adminSession"; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String path = request.getRequestURI(); + + if (path.startsWith("/v1/admin/") && !path.startsWith("/v1/admin/auth/")) { + Optional sessionOpt = adminAuthService.getAuthenticatedSession(request); + if (sessionOpt.isEmpty()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"success\":false,\"message\":\"Unauthorized\"}"); + return; + } + + AdminAuthService.AdminSession session = sessionOpt.get(); + request.setAttribute(ADMIN_SESSION_ATTR, session); + + List authorities = List.of( + new SimpleGrantedAuthority(RESTSecurityRole.ADMIN) + ); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + session.email(), + null, + authorities + ); + authentication.setDetails(session); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java b/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java index 13e6ba2..62096c6 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java @@ -1,45 +1,45 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.infrastructure.config.ModlDevProperties; -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.server.ServerService; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.web.servlet.FilterRegistrationBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.Ordered; - -@Configuration -@RequiredArgsConstructor -public class FilterConfig { - private final ServerService serverService; - private final ApiKeyFilter apiKeyFilter; - private final ModlProperties modlProperties; - private final ModlDevProperties devProperties; - private final ModlCorsProperties corsProperties; - - @Bean - public FilterRegistrationBean serverDomainFilter() { - final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); - registrationBean.setFilter(new ServerHeaderFilter( - serverService, - modlProperties.isDevelopmentMode(), - devProperties.getServerDomain(), - corsProperties.getSystemOrigins() - )); - registrationBean.addUrlPatterns(RESTMappingV1.PREFIX_PANEL + "/*", RESTMappingV1.PREFIX_PUBLIC + "/*"); - registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 2); - - return registrationBean; - } - - @Bean - public FilterRegistrationBean apiKeyFilterRegistration() { - final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(apiKeyFilter); - registrationBean.setEnabled(false); - - return registrationBean; - } -} +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.infrastructure.config.ModlDevProperties; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.server.ServerService; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +@Configuration +@RequiredArgsConstructor +public class FilterConfig { + private final ServerService serverService; + private final ApiKeyFilter apiKeyFilter; + private final ModlProperties modlProperties; + private final ModlDevProperties devProperties; + private final ModlCorsProperties corsProperties; + + @Bean + public FilterRegistrationBean serverDomainFilter() { + final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(new ServerHeaderFilter( + serverService, + modlProperties.isDevelopmentMode(), + devProperties.getServerDomain(), + corsProperties.getSystemOrigins() + )); + registrationBean.addUrlPatterns(RESTMappingV1.PREFIX_PANEL + "/*", RESTMappingV1.PREFIX_PUBLIC + "/*"); + registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 2); + + return registrationBean; + } + + @Bean + public FilterRegistrationBean apiKeyFilterRegistration() { + final FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(apiKeyFilter); + registrationBean.setEnabled(false); + + return registrationBean; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java index 445951a..31d5122 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java @@ -1,233 +1,233 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestAttribute; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -@Component -@RequiredArgsConstructor -public class PanelPermissionFilter extends OncePerRequestFilter { - private final PermissionService permissionService; - private final StaffService staffService; - private static final List FIXED_PERMISSIONS = List.of( - new PermissionMapping(RESTMappingV1.PANEL_STAFF, "admin.staff.manage.members"), - new PermissionMapping(RESTMappingV1.PANEL_ROLES, "admin.staff.manage.roles"), - new PermissionMapping(RESTMappingV1.PANEL_DASHBOARD, "admin.audit.view.dashboard"), - new PermissionMapping(RESTMappingV1.PANEL_ANALYTICS, "admin.audit.view.analytics"), - new PermissionMapping(RESTMappingV1.PANEL_AUDIT, "admin.audit.view.logs"), - new PermissionMapping(RESTMappingV1.PANEL_LOGS, "admin.audit.view.logs"), - new PermissionMapping(RESTMappingV1.PANEL_REPLAYS, "punishment.modify") - ); - private static final String PERMIT = "__PERMIT__"; - private static final String PLAYER_READ = "__PLAYER_READ__"; - private static final List RW_PERMISSIONS = List.of( - new PermissionMapping(RESTMappingV1.PANEL_BILLING, "admin.settings.view.billing", "admin.settings.modify.billing"), - new PermissionMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS, "admin.settings.view.content", "admin.settings.modify.content"), - new PermissionMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE, "admin.settings.view.content", "admin.settings.modify.content"), - new PermissionMapping(RESTMappingV1.PANEL_MEDIA, "admin.settings.view.content", "admin.settings.modify.content"), - new PermissionMapping(RESTMappingV1.PANEL_MIGRATION, "admin.settings.view.migration", "admin.settings.modify.migration"), - new PermissionMapping(RESTMappingV1.PANEL_STORAGE, "admin.settings.view.storage", "admin.settings.modify.storage"), - new PermissionMapping(RESTMappingV1.PANEL_TICKET_SUBSCRIPTIONS, "ticket.view.all", "ticket.reply.all"), - new PermissionMapping(RESTMappingV1.PANEL_TICKETS, "ticket.view.all", "ticket.reply.all"), - new PermissionMapping(RESTMappingV1.PANEL_APPEALS, "ticket.view.all", "appeal.modify"), - new PermissionMapping(RESTMappingV1.PANEL_SERVER, "admin.settings.view", "admin.settings.modify") - ); - private static final Set SETTINGS_PUNISHMENT_PATHS = Set.of( - "/punishment-types", "/status-thresholds", "/ai-moderation", - "/ai-dismiss-suggestion" - ); - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String path = request.getRequestURI(); - if (!path.startsWith(RESTMappingV1.PREFIX_PANEL)) { - return true; - } - return startsWithEndpoint(path, RESTMappingV1.PANEL_AUTH); - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, - @NotNull HttpServletResponse response, - @NotNull FilterChain filterChain - ) throws ServletException, IOException { - Server server = (Server) request.getAttribute(RequestAttribute.SERVER); - String email = RequestUtil.getSessionEmail(request); - if (server == null || email == null) { - deny(response); - return; - } - - if (isPanelDashboardAlertsRead(request)) { - filterChain.doFilter(request, response); - return; - } - - if (permissionService.isSuperAdmin(server, email)) { - filterChain.doFilter(request, response); - return; - } - - String requiredPermission = resolveRequiredPermission(request.getRequestURI(), request.getMethod()); - if (requiredPermission == null) { - deny(response); - return; - } - - if (PERMIT.equals(requiredPermission)) { - filterChain.doFilter(request, response); - return; - } - - Optional staffOpt = staffService.getStaffByEmail(server, email); - String roleId = staffOpt.map(staff -> RoleAuthorization.effectiveRoleId(server, staff)).orElse(null); - - boolean authorized; - if (PLAYER_READ.equals(requiredPermission)) { - authorized = hasPlayerReadAccess(server, roleId); - } else { - authorized = roleId != null - && hasRequiredPermission(server, roleId, requiredPermission, request.getRequestURI(), request.getMethod()); - } - if (!authorized) { - deny(response); - return; - } - - filterChain.doFilter(request, response); - } - - private boolean hasPlayerReadAccess(Server server, String roleId) { - return roleId != null - && (permissionService.hasPermission(server, roleId, "punishment.view") - || permissionService.hasPermission(server, roleId, "punishment.modify") - || permissionService.hasAnyPermissionWithPrefix(server, roleId, "punishment.apply.")); - } - - private boolean hasRequiredPermission(Server server, String role, String requiredPermission, String path, String method) { - if (permissionService.hasPermission(server, role, requiredPermission)) { - return true; - } - return isAppealReplyWrite(path, method) - && "appeal.modify".equals(requiredPermission) - && permissionService.hasPermission(server, role, "ticket.reply.all"); - } - - private boolean isAppealReplyWrite(String path, String method) { - return "POST".equalsIgnoreCase(method) - && startsWithEndpoint(path, RESTMappingV1.PANEL_APPEALS) - && path.endsWith("/replies"); - } - - private boolean isPanelDashboardAlertsRead(HttpServletRequest request) { - return isReadOnly(request.getMethod()) - && startsWithEndpoint(request.getRequestURI(), RESTMappingV1.PANEL_DASHBOARD + "/alerts"); - } - - private void deny(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json"); - response.getWriter().write("{\"success\":false,\"status\":403,\"error\":\"Insufficient permissions\",\"message\":\"Insufficient permissions\"}"); - } - - private String resolveRequiredPermission(String path, String method) { - for (PermissionMapping mapping : FIXED_PERMISSIONS) { - if (startsWithEndpoint(path, mapping.endpoint())) { - return mapping.readPermission(); - } - } - - if (startsWithEndpoint(path, RESTMappingV1.PANEL_PLAYERS)) { - return resolvePlayersPermission(path, method); - } - - if (startsWithEndpoint(path, RESTMappingV1.PANEL_TICKETS + "/bulk")) { - return isReadOnly(method) ? "ticket.view.all" : "ticket.close.all"; - } - - if (startsWithEndpoint(path, RESTMappingV1.PANEL_SETTINGS)) { - return resolveSettingsPermission(path, method); - } - - for (PermissionMapping mapping : RW_PERMISSIONS) { - if (startsWithEndpoint(path, mapping.endpoint())) { - return isReadOnly(method) ? mapping.readPermission() : mapping.writePermission(); - } - } - - return null; - } - - private String resolvePlayersPermission(String path, String method) { - if (isReadOnly(method)) { - return PLAYER_READ; - } - if ("POST".equalsIgnoreCase(method) && isCreatePunishmentPath(path)) { - return PERMIT; - } - return "punishment.modify"; - } - - private boolean isCreatePunishmentPath(String path) { - String prefix = RESTMappingV1.PANEL_PLAYERS + "/"; - if (!path.startsWith(prefix)) { - return false; - } - String[] segments = path.substring(prefix.length()).split("/"); - return segments.length == 2 - && !segments[0].isEmpty() - && "punishments".equals(segments[1]); - } - - private String resolveSettingsPermission(String path, String method) { - String base = RESTMappingV1.PANEL_SETTINGS; - - if (startsWithEndpoint(path, base + "/ai-apply-punishment")) { - return PERMIT; - } - - for (String suffix : SETTINGS_PUNISHMENT_PATHS) { - if (startsWithEndpoint(path, base + suffix)) { - return isReadOnly(method) ? "admin.settings.view.punishments" : "admin.settings.modify.punishments"; - } - } - - if (startsWithEndpoint(path, base + "/domain")) { - return isReadOnly(method) ? "admin.settings.view.domain" : "admin.settings.modify.domain"; - } - - return isReadOnly(method) ? "admin.settings.view" : "admin.settings.modify"; - } - - private boolean isReadOnly(String method) { - return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method); - } - - private boolean startsWithEndpoint(String path, String endpoint) { - return path.equals(endpoint) || path.startsWith(endpoint + "/"); - } - - private record PermissionMapping(String endpoint, String readPermission, String writePermission) { - PermissionMapping(String endpoint, String permission) { - this(endpoint, permission, permission); - } - } -} +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.staff.service.StaffService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class PanelPermissionFilter extends OncePerRequestFilter { + private final PermissionService permissionService; + private final StaffService staffService; + private static final List FIXED_PERMISSIONS = List.of( + new PermissionMapping(RESTMappingV1.PANEL_STAFF, "admin.staff.manage.members"), + new PermissionMapping(RESTMappingV1.PANEL_ROLES, "admin.staff.manage.roles"), + new PermissionMapping(RESTMappingV1.PANEL_DASHBOARD, "admin.audit.view.dashboard"), + new PermissionMapping(RESTMappingV1.PANEL_ANALYTICS, "admin.audit.view.analytics"), + new PermissionMapping(RESTMappingV1.PANEL_AUDIT, "admin.audit.view.logs"), + new PermissionMapping(RESTMappingV1.PANEL_LOGS, "admin.audit.view.logs"), + new PermissionMapping(RESTMappingV1.PANEL_REPLAYS, "punishment.modify") + ); + private static final String PERMIT = "__PERMIT__"; + private static final String PLAYER_READ = "__PLAYER_READ__"; + private static final List RW_PERMISSIONS = List.of( + new PermissionMapping(RESTMappingV1.PANEL_BILLING, "admin.settings.view.billing", "admin.settings.modify.billing"), + new PermissionMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS, "admin.settings.view.content", "admin.settings.modify.content"), + new PermissionMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE, "admin.settings.view.content", "admin.settings.modify.content"), + new PermissionMapping(RESTMappingV1.PANEL_MEDIA, "admin.settings.view.content", "admin.settings.modify.content"), + new PermissionMapping(RESTMappingV1.PANEL_MIGRATION, "admin.settings.view.migration", "admin.settings.modify.migration"), + new PermissionMapping(RESTMappingV1.PANEL_STORAGE, "admin.settings.view.storage", "admin.settings.modify.storage"), + new PermissionMapping(RESTMappingV1.PANEL_TICKET_SUBSCRIPTIONS, "ticket.view.all", "ticket.reply.all"), + new PermissionMapping(RESTMappingV1.PANEL_TICKETS, "ticket.view.all", "ticket.reply.all"), + new PermissionMapping(RESTMappingV1.PANEL_APPEALS, "ticket.view.all", "appeal.modify"), + new PermissionMapping(RESTMappingV1.PANEL_SERVER, "admin.settings.view", "admin.settings.modify") + ); + private static final Set SETTINGS_PUNISHMENT_PATHS = Set.of( + "/punishment-types", "/status-thresholds", "/ai-moderation", + "/ai-dismiss-suggestion" + ); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + if (!path.startsWith(RESTMappingV1.PREFIX_PANEL)) { + return true; + } + return startsWithEndpoint(path, RESTMappingV1.PANEL_AUTH); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NotNull HttpServletResponse response, + @NotNull FilterChain filterChain + ) throws ServletException, IOException { + Server server = (Server) request.getAttribute(RequestAttribute.SERVER); + String email = RequestUtil.getSessionEmail(request); + if (server == null || email == null) { + deny(response); + return; + } + + if (isPanelDashboardAlertsRead(request)) { + filterChain.doFilter(request, response); + return; + } + + if (permissionService.isSuperAdmin(server, email)) { + filterChain.doFilter(request, response); + return; + } + + String requiredPermission = resolveRequiredPermission(request.getRequestURI(), request.getMethod()); + if (requiredPermission == null) { + deny(response); + return; + } + + if (PERMIT.equals(requiredPermission)) { + filterChain.doFilter(request, response); + return; + } + + Optional staffOpt = staffService.getStaffByEmail(server, email); + String roleId = staffOpt.map(staff -> RoleAuthorization.effectiveRoleId(server, staff)).orElse(null); + + boolean authorized; + if (PLAYER_READ.equals(requiredPermission)) { + authorized = hasPlayerReadAccess(server, roleId); + } else { + authorized = roleId != null + && hasRequiredPermission(server, roleId, requiredPermission, request.getRequestURI(), request.getMethod()); + } + if (!authorized) { + deny(response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean hasPlayerReadAccess(Server server, String roleId) { + return roleId != null + && (permissionService.hasPermission(server, roleId, "punishment.view") + || permissionService.hasPermission(server, roleId, "punishment.modify") + || permissionService.hasAnyPermissionWithPrefix(server, roleId, "punishment.apply.")); + } + + private boolean hasRequiredPermission(Server server, String role, String requiredPermission, String path, String method) { + if (permissionService.hasPermission(server, role, requiredPermission)) { + return true; + } + return isAppealReplyWrite(path, method) + && "appeal.modify".equals(requiredPermission) + && permissionService.hasPermission(server, role, "ticket.reply.all"); + } + + private boolean isAppealReplyWrite(String path, String method) { + return "POST".equalsIgnoreCase(method) + && startsWithEndpoint(path, RESTMappingV1.PANEL_APPEALS) + && path.endsWith("/replies"); + } + + private boolean isPanelDashboardAlertsRead(HttpServletRequest request) { + return isReadOnly(request.getMethod()) + && startsWithEndpoint(request.getRequestURI(), RESTMappingV1.PANEL_DASHBOARD + "/alerts"); + } + + private void deny(HttpServletResponse response) throws IOException { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json"); + response.getWriter().write("{\"success\":false,\"status\":403,\"error\":\"Insufficient permissions\",\"message\":\"Insufficient permissions\"}"); + } + + private String resolveRequiredPermission(String path, String method) { + for (PermissionMapping mapping : FIXED_PERMISSIONS) { + if (startsWithEndpoint(path, mapping.endpoint())) { + return mapping.readPermission(); + } + } + + if (startsWithEndpoint(path, RESTMappingV1.PANEL_PLAYERS)) { + return resolvePlayersPermission(path, method); + } + + if (startsWithEndpoint(path, RESTMappingV1.PANEL_TICKETS + "/bulk")) { + return isReadOnly(method) ? "ticket.view.all" : "ticket.close.all"; + } + + if (startsWithEndpoint(path, RESTMappingV1.PANEL_SETTINGS)) { + return resolveSettingsPermission(path, method); + } + + for (PermissionMapping mapping : RW_PERMISSIONS) { + if (startsWithEndpoint(path, mapping.endpoint())) { + return isReadOnly(method) ? mapping.readPermission() : mapping.writePermission(); + } + } + + return null; + } + + private String resolvePlayersPermission(String path, String method) { + if (isReadOnly(method)) { + return PLAYER_READ; + } + if ("POST".equalsIgnoreCase(method) && isCreatePunishmentPath(path)) { + return PERMIT; + } + return "punishment.modify"; + } + + private boolean isCreatePunishmentPath(String path) { + String prefix = RESTMappingV1.PANEL_PLAYERS + "/"; + if (!path.startsWith(prefix)) { + return false; + } + String[] segments = path.substring(prefix.length()).split("/"); + return segments.length == 2 + && !segments[0].isEmpty() + && "punishments".equals(segments[1]); + } + + private String resolveSettingsPermission(String path, String method) { + String base = RESTMappingV1.PANEL_SETTINGS; + + if (startsWithEndpoint(path, base + "/ai-apply-punishment")) { + return PERMIT; + } + + for (String suffix : SETTINGS_PUNISHMENT_PATHS) { + if (startsWithEndpoint(path, base + suffix)) { + return isReadOnly(method) ? "admin.settings.view.punishments" : "admin.settings.modify.punishments"; + } + } + + if (startsWithEndpoint(path, base + "/domain")) { + return isReadOnly(method) ? "admin.settings.view.domain" : "admin.settings.modify.domain"; + } + + return isReadOnly(method) ? "admin.settings.view" : "admin.settings.modify"; + } + + private boolean isReadOnly(String method) { + return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method); + } + + private boolean startsWithEndpoint(String path, String endpoint) { + return path.equals(endpoint) || path.startsWith(endpoint + "/"); + } + + private record PermissionMapping(String endpoint, String readPermission, String writePermission) { + PermissionMapping(String endpoint, String permission) { + this(endpoint, permission, permission); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java b/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java index 2b9d145..7ce16e2 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java @@ -1,51 +1,51 @@ -package gg.modl.backend.infrastructure.filter; - -import gg.modl.backend.infrastructure.filter.AdminAuthFilter; -import gg.modl.backend.infrastructure.filter.SessionAuthenticationFilter; -import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; +package gg.modl.backend.infrastructure.filter; + +import gg.modl.backend.infrastructure.filter.AdminAuthFilter; +import gg.modl.backend.infrastructure.filter.SessionAuthenticationFilter; +import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RESTMappingV2; import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; -import lombok.RequiredArgsConstructor; -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.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class V1SecurityConfig { - private final SessionAuthenticationFilter sessionAuthenticationFilter; - private final ApiKeyFilter apiKeyFilter; - private final AdminAuthFilter adminAuthFilter; - private final PanelPermissionFilter panelPermissionFilter; - private final OriginCsrfFilter originCsrfFilter; - private final DynamicCorsConfigurationSource dynamicCorsConfigurationSource; - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http - .cors(cors -> cors.configurationSource(dynamicCorsConfigurationSource)) - .csrf(AbstractHttpConfigurer::disable) - .headers(headers -> headers - .frameOptions(fo -> fo.deny()) - .contentTypeOptions(cto -> {}) - .httpStrictTransportSecurity(hsts -> hsts - .includeSubDomains(true) - .maxAgeInSeconds(31536000) - ) - ) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(c -> c - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.HEAD, "/v1").permitAll() +import lombok.RequiredArgsConstructor; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class V1SecurityConfig { + private final SessionAuthenticationFilter sessionAuthenticationFilter; + private final ApiKeyFilter apiKeyFilter; + private final AdminAuthFilter adminAuthFilter; + private final PanelPermissionFilter panelPermissionFilter; + private final OriginCsrfFilter originCsrfFilter; + private final DynamicCorsConfigurationSource dynamicCorsConfigurationSource; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + return http + .cors(cors -> cors.configurationSource(dynamicCorsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) + .headers(headers -> headers + .frameOptions(fo -> fo.deny()) + .contentTypeOptions(cto -> {}) + .httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31536000) + ) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(c -> c + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + .requestMatchers(HttpMethod.HEAD, "/v1").permitAll() .requestMatchers("/error").permitAll() .requestMatchers("/actuator/health", "/actuator/health/**", "/actuator/prometheus").permitAll() .requestMatchers(RESTMappingV1.HEALTH).permitAll() @@ -62,13 +62,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(RESTMappingV3.PREFIX_MINECRAFT + "/**").hasAuthority(RESTSecurityRole.MINECRAFT) .anyRequest().authenticated() ) - .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(sessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(adminAuthFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterAfter(panelPermissionFilter, SessionAuthenticationFilter.class) - .addFilterAfter(originCsrfFilter, AdminAuthFilter.class) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .build(); - } -} + .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(sessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(adminAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(panelPermissionFilter, SessionAuthenticationFilter.class) + .addFilterAfter(originCsrfFilter, AdminAuthFilter.class) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .build(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java b/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java index b537723..bd5a49f 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/HealthController.java @@ -1,26 +1,26 @@ -package gg.modl.backend.infrastructure.rest; - -import java.util.Map; -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.RequestMethod; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.HEALTH) -public class HealthController { - - @GetMapping - public ResponseEntity> health() { - return ResponseEntity.ok(Map.of( - "status", "ok", - "timestamp", System.currentTimeMillis() - )); - } - - @RequestMapping(method = RequestMethod.HEAD) - public ResponseEntity healthHead() { - return ResponseEntity.ok().build(); - } -} +package gg.modl.backend.infrastructure.rest; + +import java.util.Map; +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.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.HEALTH) +public class HealthController { + + @GetMapping + public ResponseEntity> health() { + return ResponseEntity.ok(Map.of( + "status", "ok", + "timestamp", System.currentTimeMillis() + )); + } + + @RequestMapping(method = RequestMethod.HEAD) + public ResponseEntity healthHead() { + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java b/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java index 7efe416..ad5d4bf 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java @@ -1,8 +1,8 @@ -package gg.modl.backend.infrastructure.rest; - -public final class RESTSecurityRole { - public static final String USER = "USER"; - public static final String ADMIN = "ADMIN"; - public static final String MINECRAFT = "MINECRAFT"; - public static final String ADMIN_SESSION_COOKIE = "modl.admin.session"; -} +package gg.modl.backend.infrastructure.rest; + +public final class RESTSecurityRole { + public static final String USER = "USER"; + public static final String ADMIN = "ADMIN"; + public static final String MINECRAFT = "MINECRAFT"; + public static final String ADMIN_SESSION_COOKIE = "modl.admin.session"; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java b/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java index c69ba32..e8f194b 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java @@ -1,5 +1,5 @@ -package gg.modl.backend.infrastructure.rest; - +package gg.modl.backend.infrastructure.rest; + public final class RequestAttribute { public static final String SERVER = "gg.modl.attribute.server"; public static final String SERVER_DOMAIN = "gg.modl.attribute.serverDomain"; diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java b/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java index 441a366..c20ee88 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java @@ -1,5 +1,5 @@ -package gg.modl.backend.infrastructure.rest; - +package gg.modl.backend.infrastructure.rest; + public final class RequestHeader { public static final String SERVER_DOMAIN = "X-Server-Domain"; public static final String FORWARDED_HOST = "X-Forwarded-Host"; diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java b/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java index f4fe46a..cb61d3c 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java @@ -1,209 +1,209 @@ -package gg.modl.backend.infrastructure.rest; - -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.server.data.Server; -import jakarta.servlet.http.HttpServletRequest; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Objects; -import java.util.regex.Pattern; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public final class RequestUtil { - private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RequestUtil.class); - private static volatile boolean warnedAboutProxy = false; - private static final SecureRandom RANDOM = new SecureRandom(); - private static final boolean TRUST_PROXY_HEADERS = resolveTrustProxyHeaders(); - private static final String CLIENT_IP_HEADER = resolveClientIpHeaderName(); - private static final int TRUSTED_PROXY_COUNT = resolveTrustedProxyCount(); - private static final Pattern IPV6_LITERAL_CHARS = Pattern.compile("[0-9A-Fa-f:.%]+"); - - private static boolean resolveTrustProxyHeaders() { - String value = System.getProperty("modl.trust-proxy-headers"); - if (value == null) { - value = System.getProperty("MODL_TRUST_PROXY_HEADERS"); - } - if (value == null) { - value = System.getenv("MODL_TRUST_PROXY_HEADERS"); - } - return Boolean.parseBoolean(value); - } - - private static String resolveClientIpHeaderName() { - String value = System.getProperty("modl.client-ip-header"); - if (value == null) { - value = System.getProperty("MODL_CLIENT_IP_HEADER"); - } - if (value == null) { - value = System.getenv("MODL_CLIENT_IP_HEADER"); - } - if (value == null || value.isBlank()) { - return "CF-Connecting-IP"; - } - return value.trim(); - } - - private static int resolveTrustedProxyCount() { - String value = System.getProperty("modl.trusted-proxy-count"); - if (value == null) { - value = System.getProperty("MODL_TRUSTED_PROXY_COUNT"); - } - if (value == null) { - value = System.getenv("MODL_TRUSTED_PROXY_COUNT"); - } - if (value == null || value.isBlank()) { - return 1; - } - try { - return Math.max(0, Integer.parseInt(value.trim())); - } catch (NumberFormatException e) { - return 1; - } - } - - @NotNull - public static Server getRequestServer(HttpServletRequest request) { - return Objects.requireNonNull((Server) request.getAttribute(RequestAttribute.SERVER), "Server should not be null if being called from panel route!"); - } - - @Nullable - public static String getSessionEmail(HttpServletRequest request) { - AuthSessionData session = getSession(request); - return session != null ? session.getEmail() : null; - } - - @Nullable - public static AuthSessionData getSession(HttpServletRequest request) { - return (AuthSessionData) request.getAttribute(RequestAttribute.SESSION); - } - - @Nullable - public static String getActingStaffId(HttpServletRequest request) { - String actingStaffId = request.getHeader("X-Acting-Staff-Id"); - if (actingStaffId == null) { - return null; - } - String trimmed = actingStaffId.trim(); - return trimmed.isEmpty() ? null : trimmed; - } - - @NotNull - public static String getCurrentUsername(HttpServletRequest request) { - AuthSessionData session = getSession(request); - if (session == null || session.getEmail() == null) { - return "Unknown"; - } - // Use email as username fallback - the service layer should resolve actual username if needed - return session.getEmail(); - } - - public static String getClientIp(HttpServletRequest request) { - if (!TRUST_PROXY_HEADERS) { - warnAboutUntrustedForwardingHeaderOnce(request); - return request.getRemoteAddr(); - } - - String authoritative = request.getHeader(CLIENT_IP_HEADER); - if (authoritative != null && isValidIp(authoritative.trim())) { - return authoritative.trim(); - } - - String forwarded = firstValidIp(request.getHeader("X-Forwarded-For"), TRUSTED_PROXY_COUNT); - if (forwarded != null) { - return forwarded; - } - - String realIp = request.getHeader("X-Real-IP"); - if (realIp != null && isValidIp(realIp.trim())) { - return realIp.trim(); - } - - return request.getRemoteAddr(); - } - - private static void warnAboutUntrustedForwardingHeaderOnce(HttpServletRequest request) { - if (warnedAboutProxy) { - return; - } - String xForwardedFor = request.getHeader("X-Forwarded-For"); - if (xForwardedFor == null || xForwardedFor.isEmpty()) { - return; - } - log.warn("Request has X-Forwarded-For header ({}) but MODL_TRUST_PROXY_HEADERS is not set. " - + "Client IP will be reported as {}. Set MODL_TRUST_PROXY_HEADERS=true if running behind a proxy.", - xForwardedFor, request.getRemoteAddr()); - warnedAboutProxy = true; - } - - static String firstValidIp(String headerValue, int trustedProxyCount) { - if (headerValue == null || headerValue.isEmpty()) { - return null; - } - String[] entries = headerValue.split(","); - int index = entries.length - trustedProxyCount; - if (index < 0 || index >= entries.length) { - return null; - } - String candidate = entries[index].trim(); - return isValidIp(candidate) ? candidate : null; - } - - private static boolean isValidIp(String value) { - if (value == null) { - return false; - } - String candidate = value.trim(); - if (candidate.isEmpty()) { - return false; - } - if (candidate.length() > 1 && candidate.charAt(0) == '[' && candidate.charAt(candidate.length() - 1) == ']') { - candidate = candidate.substring(1, candidate.length() - 1); - } - if (candidate.indexOf(':') < 0) { - return isIpv4Literal(candidate); - } - if (!IPV6_LITERAL_CHARS.matcher(candidate).matches()) { - return false; - } - try { - InetAddress.getByName(candidate); - return true; - } catch (UnknownHostException e) { - return false; - } - } - - private static boolean isIpv4Literal(String value) { - String[] octets = value.split("\\.", -1); - if (octets.length != 4) { - return false; - } - for (String octet : octets) { - if (octet.isEmpty() || octet.length() > 3) { - return false; - } - for (int i = 0; i < octet.length(); i++) { - if (!Character.isDigit(octet.charAt(i))) { - return false; - } - } - if (Integer.parseInt(octet) > 255) { - return false; - } - } - return true; - } - - public static boolean trustsProxyHeaders() { - return TRUST_PROXY_HEADERS; - } - - public static String generateSecureToken(int byteLength) { - byte[] bytes = new byte[byteLength]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } -} +package gg.modl.backend.infrastructure.rest; + +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.server.data.Server; +import jakarta.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Objects; +import java.util.regex.Pattern; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class RequestUtil { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RequestUtil.class); + private static volatile boolean warnedAboutProxy = false; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final boolean TRUST_PROXY_HEADERS = resolveTrustProxyHeaders(); + private static final String CLIENT_IP_HEADER = resolveClientIpHeaderName(); + private static final int TRUSTED_PROXY_COUNT = resolveTrustedProxyCount(); + private static final Pattern IPV6_LITERAL_CHARS = Pattern.compile("[0-9A-Fa-f:.%]+"); + + private static boolean resolveTrustProxyHeaders() { + String value = System.getProperty("modl.trust-proxy-headers"); + if (value == null) { + value = System.getProperty("MODL_TRUST_PROXY_HEADERS"); + } + if (value == null) { + value = System.getenv("MODL_TRUST_PROXY_HEADERS"); + } + return Boolean.parseBoolean(value); + } + + private static String resolveClientIpHeaderName() { + String value = System.getProperty("modl.client-ip-header"); + if (value == null) { + value = System.getProperty("MODL_CLIENT_IP_HEADER"); + } + if (value == null) { + value = System.getenv("MODL_CLIENT_IP_HEADER"); + } + if (value == null || value.isBlank()) { + return "CF-Connecting-IP"; + } + return value.trim(); + } + + private static int resolveTrustedProxyCount() { + String value = System.getProperty("modl.trusted-proxy-count"); + if (value == null) { + value = System.getProperty("MODL_TRUSTED_PROXY_COUNT"); + } + if (value == null) { + value = System.getenv("MODL_TRUSTED_PROXY_COUNT"); + } + if (value == null || value.isBlank()) { + return 1; + } + try { + return Math.max(0, Integer.parseInt(value.trim())); + } catch (NumberFormatException e) { + return 1; + } + } + + @NotNull + public static Server getRequestServer(HttpServletRequest request) { + return Objects.requireNonNull((Server) request.getAttribute(RequestAttribute.SERVER), "Server should not be null if being called from panel route!"); + } + + @Nullable + public static String getSessionEmail(HttpServletRequest request) { + AuthSessionData session = getSession(request); + return session != null ? session.getEmail() : null; + } + + @Nullable + public static AuthSessionData getSession(HttpServletRequest request) { + return (AuthSessionData) request.getAttribute(RequestAttribute.SESSION); + } + + @Nullable + public static String getActingStaffId(HttpServletRequest request) { + String actingStaffId = request.getHeader("X-Acting-Staff-Id"); + if (actingStaffId == null) { + return null; + } + String trimmed = actingStaffId.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + @NotNull + public static String getCurrentUsername(HttpServletRequest request) { + AuthSessionData session = getSession(request); + if (session == null || session.getEmail() == null) { + return "Unknown"; + } + // Use email as username fallback - the service layer should resolve actual username if needed + return session.getEmail(); + } + + public static String getClientIp(HttpServletRequest request) { + if (!TRUST_PROXY_HEADERS) { + warnAboutUntrustedForwardingHeaderOnce(request); + return request.getRemoteAddr(); + } + + String authoritative = request.getHeader(CLIENT_IP_HEADER); + if (authoritative != null && isValidIp(authoritative.trim())) { + return authoritative.trim(); + } + + String forwarded = firstValidIp(request.getHeader("X-Forwarded-For"), TRUSTED_PROXY_COUNT); + if (forwarded != null) { + return forwarded; + } + + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && isValidIp(realIp.trim())) { + return realIp.trim(); + } + + return request.getRemoteAddr(); + } + + private static void warnAboutUntrustedForwardingHeaderOnce(HttpServletRequest request) { + if (warnedAboutProxy) { + return; + } + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor == null || xForwardedFor.isEmpty()) { + return; + } + log.warn("Request has X-Forwarded-For header ({}) but MODL_TRUST_PROXY_HEADERS is not set. " + + "Client IP will be reported as {}. Set MODL_TRUST_PROXY_HEADERS=true if running behind a proxy.", + xForwardedFor, request.getRemoteAddr()); + warnedAboutProxy = true; + } + + static String firstValidIp(String headerValue, int trustedProxyCount) { + if (headerValue == null || headerValue.isEmpty()) { + return null; + } + String[] entries = headerValue.split(","); + int index = entries.length - trustedProxyCount; + if (index < 0 || index >= entries.length) { + return null; + } + String candidate = entries[index].trim(); + return isValidIp(candidate) ? candidate : null; + } + + private static boolean isValidIp(String value) { + if (value == null) { + return false; + } + String candidate = value.trim(); + if (candidate.isEmpty()) { + return false; + } + if (candidate.length() > 1 && candidate.charAt(0) == '[' && candidate.charAt(candidate.length() - 1) == ']') { + candidate = candidate.substring(1, candidate.length() - 1); + } + if (candidate.indexOf(':') < 0) { + return isIpv4Literal(candidate); + } + if (!IPV6_LITERAL_CHARS.matcher(candidate).matches()) { + return false; + } + try { + InetAddress.getByName(candidate); + return true; + } catch (UnknownHostException e) { + return false; + } + } + + private static boolean isIpv4Literal(String value) { + String[] octets = value.split("\\.", -1); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (octet.isEmpty() || octet.length() > 3) { + return false; + } + for (int i = 0; i < octet.length(); i++) { + if (!Character.isDigit(octet.charAt(i))) { + return false; + } + } + if (Integer.parseInt(octet) > 255) { + return false; + } + } + return true; + } + + public static boolean trustsProxyHeaders() { + return TRUST_PROXY_HEADERS; + } + + public static String generateSecureToken(int byteLength) { + byte[] bytes = new byte[byteLength]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java index 2f704c4..9dc0932 100644 --- a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java +++ b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileConfiguration.java @@ -1,20 +1,20 @@ -package gg.modl.backend.infrastructure.turnstile; - -import java.util.ArrayList; -import java.util.List; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; -import org.springframework.validation.annotation.Validated; - -@Getter -@Setter -@Validated -@Configuration -@ConfigurationProperties(prefix = "modl.turnstile") -public class TurnstileConfiguration { - private String secretKey; - private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; - private List expectedHostnames = new ArrayList<>(); -} +package gg.modl.backend.infrastructure.turnstile; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.validation.annotation.Validated; + +@Getter +@Setter +@Validated +@Configuration +@ConfigurationProperties(prefix = "modl.turnstile") +public class TurnstileConfiguration { + private String secretKey; + private String verifyUrl = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + private List expectedHostnames = new ArrayList<>(); +} diff --git a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java index 22489f6..e60e704 100644 --- a/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java +++ b/src/main/java/gg/modl/backend/infrastructure/turnstile/TurnstileService.java @@ -1,137 +1,137 @@ -package gg.modl.backend.infrastructure.turnstile; - -import com.fasterxml.jackson.annotation.JsonProperty; -import gg.modl.backend.infrastructure.config.ModlProperties; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -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 -@RequiredArgsConstructor -@Slf4j -public class TurnstileService { - private final RestTemplate restTemplate; - private final TurnstileConfiguration config; - private final ModlProperties modlProperties; - private volatile boolean warnedAboutMissingHostnames = false; - - public boolean validateToken(String token, String remoteIp) { - if (config.getSecretKey() == null || config.getSecretKey().isBlank()) { - if (modlProperties.isDevelopmentMode()) { - log.warn("Turnstile secret key not configured in development mode, skipping validation"); - return true; - } - - log.error("Turnstile secret key not configured, rejecting request"); - return false; - } - - try { - final HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); - - final MultiValueMap body = new LinkedMultiValueMap<>(); - body.add("secret", config.getSecretKey()); - body.add("response", token); - if (remoteIp != null && !remoteIp.isEmpty()) { - body.add("remoteip", remoteIp); - } - - final HttpEntity> request = new HttpEntity<>(body, headers); - final TurnstileResponse response = restTemplate.postForObject( - config.getVerifyUrl(), - request, - TurnstileResponse.class - ); - - if (response == null) { - log.error("Turnstile validation returned null response"); - return false; - } - - if (!response.success()) { - log.warn("Turnstile validation rejected token, error-codes={}", response.errorCodes()); - return false; - } - - return isHostnameAllowed(response.hostname()); - } catch (Exception e) { - log.error("Error validating Turnstile token", e); - return false; - } - } - - private boolean isHostnameAllowed(String host) { - final List allowed = resolveAllowedHostnames(); - if (allowed.isEmpty()) { - if (modlProperties.isDevelopmentMode()) { - return true; - } - warnMissingHostnamePinningOnce(); - return false; - } - if (modlProperties.isDevelopmentMode()) { - return true; - } - final boolean hostAllowed = host != null - && allowed.stream().anyMatch(h -> h.equalsIgnoreCase(host.trim())); - if (!hostAllowed) { - log.warn("Turnstile token solved on unexpected hostname '{}', expected one of {}", host, allowed); - return false; - } - return true; - } - - private List resolveAllowedHostnames() { - final List explicit = normalizeHostnames(config.getExpectedHostnames()); - if (!explicit.isEmpty()) { - return explicit; - } - return defaultHostnamesFromAppDomain(); - } - - private List normalizeHostnames(List hostnames) { - if (hostnames == null) { - return List.of(); - } - return hostnames.stream() - .filter(h -> h != null && !h.isBlank()) - .map(String::trim) - .toList(); - } - - private List defaultHostnamesFromAppDomain() { - final String appDomain = modlProperties.getAppDomain(); - if (appDomain != null && !appDomain.isBlank()) { - return List.of(appDomain.trim()); - } - final String domain = modlProperties.getDomain(); - if (domain != null && !domain.isBlank()) { - return List.of(domain.trim()); - } - return List.of(); - } - - private void warnMissingHostnamePinningOnce() { - if (warnedAboutMissingHostnames) { - return; - } - warnedAboutMissingHostnames = true; - log.error("Turnstile hostname pinning is not configured and no app domain is available; rejecting token. " - + "Set CLOUDFLARE_TURNSTILE_HOSTNAMES (modl.turnstile.expected-hostnames) to the hostname(s) serving the widget."); - } - - public record TurnstileResponse( - boolean success, - @JsonProperty("challenge_ts") String challengeTs, - String hostname, - @JsonProperty("error-codes") List errorCodes - ) {} -} +package gg.modl.backend.infrastructure.turnstile; + +import com.fasterxml.jackson.annotation.JsonProperty; +import gg.modl.backend.infrastructure.config.ModlProperties; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 +@RequiredArgsConstructor +@Slf4j +public class TurnstileService { + private final RestTemplate restTemplate; + private final TurnstileConfiguration config; + private final ModlProperties modlProperties; + private volatile boolean warnedAboutMissingHostnames = false; + + public boolean validateToken(String token, String remoteIp) { + if (config.getSecretKey() == null || config.getSecretKey().isBlank()) { + if (modlProperties.isDevelopmentMode()) { + log.warn("Turnstile secret key not configured in development mode, skipping validation"); + return true; + } + + log.error("Turnstile secret key not configured, rejecting request"); + return false; + } + + try { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("secret", config.getSecretKey()); + body.add("response", token); + if (remoteIp != null && !remoteIp.isEmpty()) { + body.add("remoteip", remoteIp); + } + + final HttpEntity> request = new HttpEntity<>(body, headers); + final TurnstileResponse response = restTemplate.postForObject( + config.getVerifyUrl(), + request, + TurnstileResponse.class + ); + + if (response == null) { + log.error("Turnstile validation returned null response"); + return false; + } + + if (!response.success()) { + log.warn("Turnstile validation rejected token, error-codes={}", response.errorCodes()); + return false; + } + + return isHostnameAllowed(response.hostname()); + } catch (Exception e) { + log.error("Error validating Turnstile token", e); + return false; + } + } + + private boolean isHostnameAllowed(String host) { + final List allowed = resolveAllowedHostnames(); + if (allowed.isEmpty()) { + if (modlProperties.isDevelopmentMode()) { + return true; + } + warnMissingHostnamePinningOnce(); + return false; + } + if (modlProperties.isDevelopmentMode()) { + return true; + } + final boolean hostAllowed = host != null + && allowed.stream().anyMatch(h -> h.equalsIgnoreCase(host.trim())); + if (!hostAllowed) { + log.warn("Turnstile token solved on unexpected hostname '{}', expected one of {}", host, allowed); + return false; + } + return true; + } + + private List resolveAllowedHostnames() { + final List explicit = normalizeHostnames(config.getExpectedHostnames()); + if (!explicit.isEmpty()) { + return explicit; + } + return defaultHostnamesFromAppDomain(); + } + + private List normalizeHostnames(List hostnames) { + if (hostnames == null) { + return List.of(); + } + return hostnames.stream() + .filter(h -> h != null && !h.isBlank()) + .map(String::trim) + .toList(); + } + + private List defaultHostnamesFromAppDomain() { + final String appDomain = modlProperties.getAppDomain(); + if (appDomain != null && !appDomain.isBlank()) { + return List.of(appDomain.trim()); + } + final String domain = modlProperties.getDomain(); + if (domain != null && !domain.isBlank()) { + return List.of(domain.trim()); + } + return List.of(); + } + + private void warnMissingHostnamePinningOnce() { + if (warnedAboutMissingHostnames) { + return; + } + warnedAboutMissingHostnames = true; + log.error("Turnstile hostname pinning is not configured and no app domain is available; rejecting token. " + + "Set CLOUDFLARE_TURNSTILE_HOSTNAMES (modl.turnstile.expected-hostnames) to the hostname(s) serving the widget."); + } + + public record TurnstileResponse( + boolean success, + @JsonProperty("challenge_ts") String challengeTs, + String hostname, + @JsonProperty("error-codes") List errorCodes + ) {} +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java index 7af1ae6..94d2703 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/ByteFormatUtil.java @@ -1,33 +1,33 @@ -package gg.modl.backend.infrastructure.util; - -import lombok.experimental.UtilityClass; - -@UtilityClass -public class ByteFormatUtil { - - private static final long KB = 1024L; - private static final long MB = KB * 1024; - private static final long GB = MB * 1024; - - public String format(long bytes) { - if (bytes < KB) { - return bytes + " B"; - } else if (bytes < MB) { - return String.format("%.2f KB", bytes / (double) KB); - } else if (bytes < GB) { - return String.format("%.2f MB", bytes / (double) MB); - } else { - return String.format("%.2f GB", bytes / (double) GB); - } - } - - public String formatCompact(long bytes) { - if (bytes < MB) { - return String.format("%.0f KB", bytes / (double) KB); - } else if (bytes < GB) { - return String.format("%.0f MB", bytes / (double) MB); - } else { - return String.format("%.1f GB", bytes / (double) GB); - } - } -} +package gg.modl.backend.infrastructure.util; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class ByteFormatUtil { + + private static final long KB = 1024L; + private static final long MB = KB * 1024; + private static final long GB = MB * 1024; + + public String format(long bytes) { + if (bytes < KB) { + return bytes + " B"; + } else if (bytes < MB) { + return String.format("%.2f KB", bytes / (double) KB); + } else if (bytes < GB) { + return String.format("%.2f MB", bytes / (double) MB); + } else { + return String.format("%.2f GB", bytes / (double) GB); + } + } + + public String formatCompact(long bytes) { + if (bytes < MB) { + return String.format("%.0f KB", bytes / (double) KB); + } else if (bytes < GB) { + return String.format("%.0f MB", bytes / (double) MB); + } else { + return String.format("%.1f GB", bytes / (double) GB); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java index e73c094..672f1d0 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java @@ -1,72 +1,72 @@ -package gg.modl.backend.infrastructure.util; - -import gg.modl.backend.auth.AuthConfiguration; -import jakarta.servlet.http.Cookie; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class CookieUtil { - - private final AuthConfiguration authConfiguration; - - public Cookie createSessionCookie(String sessionId) { - return createSessionCookie( - authConfiguration.getSessionCookieName(), - sessionId, - authConfiguration.getSessionDurationSeconds() - ); - } - - public Cookie createSessionCookie(String cookieName, String sessionId, long maxAgeSeconds) { - Cookie cookie = new Cookie(cookieName, sessionId); - cookie.setHttpOnly(true); - cookie.setSecure(authConfiguration.isCookieSecure()); - cookie.setPath("/"); - cookie.setMaxAge((int) maxAgeSeconds); - cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); - return cookie; - } - - public List createExpiredSessionCookies() { - return createExpiredSessionCookies(authConfiguration.getSessionCookieName()); - } - - public List createExpiredSessionCookies(String cookieName) { - List cookies = new ArrayList<>(); - cookies.add(createExpiredCookie(cookieName, null)); - - String domain = getConfiguredCookieDomain(); - if (domain != null) { - cookies.add(createExpiredCookie(cookieName, domain)); - if (!domain.startsWith(".")) { - cookies.add(createExpiredCookie(cookieName, "." + domain)); - } else if (domain.length() > 1) { - cookies.add(createExpiredCookie(cookieName, domain.substring(1))); - } - } - - return cookies; - } - - private Cookie createExpiredCookie(String cookieName, String domain) { - Cookie cookie = new Cookie(cookieName, ""); - cookie.setHttpOnly(true); - cookie.setSecure(authConfiguration.isCookieSecure()); - cookie.setPath("/"); - cookie.setMaxAge(0); - cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); - if (domain != null) { - cookie.setDomain(domain); - } - return cookie; - } - - private String getConfiguredCookieDomain() { - String domain = authConfiguration.getCookieDomain(); - return (domain == null || domain.isBlank()) ? null : domain; - } -} +package gg.modl.backend.infrastructure.util; + +import gg.modl.backend.auth.AuthConfiguration; +import jakarta.servlet.http.Cookie; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CookieUtil { + + private final AuthConfiguration authConfiguration; + + public Cookie createSessionCookie(String sessionId) { + return createSessionCookie( + authConfiguration.getSessionCookieName(), + sessionId, + authConfiguration.getSessionDurationSeconds() + ); + } + + public Cookie createSessionCookie(String cookieName, String sessionId, long maxAgeSeconds) { + Cookie cookie = new Cookie(cookieName, sessionId); + cookie.setHttpOnly(true); + cookie.setSecure(authConfiguration.isCookieSecure()); + cookie.setPath("/"); + cookie.setMaxAge((int) maxAgeSeconds); + cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); + return cookie; + } + + public List createExpiredSessionCookies() { + return createExpiredSessionCookies(authConfiguration.getSessionCookieName()); + } + + public List createExpiredSessionCookies(String cookieName) { + List cookies = new ArrayList<>(); + cookies.add(createExpiredCookie(cookieName, null)); + + String domain = getConfiguredCookieDomain(); + if (domain != null) { + cookies.add(createExpiredCookie(cookieName, domain)); + if (!domain.startsWith(".")) { + cookies.add(createExpiredCookie(cookieName, "." + domain)); + } else if (domain.length() > 1) { + cookies.add(createExpiredCookie(cookieName, domain.substring(1))); + } + } + + return cookies; + } + + private Cookie createExpiredCookie(String cookieName, String domain) { + Cookie cookie = new Cookie(cookieName, ""); + cookie.setHttpOnly(true); + cookie.setSecure(authConfiguration.isCookieSecure()); + cookie.setPath("/"); + cookie.setMaxAge(0); + cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); + if (domain != null) { + cookie.setDomain(domain); + } + return cookie; + } + + private String getConfiguredCookieDomain() { + String domain = authConfiguration.getCookieDomain(); + return (domain == null || domain.isBlank()) ? null : domain; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java index 4126b71..27f826d 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/DateRangeUtil.java @@ -1,53 +1,53 @@ -package gg.modl.backend.infrastructure.util; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import java.util.Date; -import lombok.experimental.UtilityClass; - -@UtilityClass -public class DateRangeUtil { - - private static final long DAY_MS = 24 * 60 * 60 * 1000L; - - public Date getStartDate(String period) { - if (period == null || "all".equals(period)) { - return null; - } - long now = System.currentTimeMillis(); - return switch (period) { - case "7d" -> new Date(now - 7 * DAY_MS); - case "90d" -> new Date(now - 90 * DAY_MS); - case "1y" -> new Date(now - 365 * DAY_MS); - default -> new Date(now - 30 * DAY_MS); - }; - } - - public Date daysAgo(int days) { - return new Date(System.currentTimeMillis() - days * DAY_MS); - } - - public Date parseEpochMillis(String value) { - if (value == null) { - return null; - } - try { - return new Date(Long.parseLong(value.trim())); - } catch (NumberFormatException exception) { - throw new ValidationException("Invalid date parameter; expected epoch milliseconds."); - } - } - - public int resolveRangeDays(String range) { - String normalized = (range == null || range.isBlank()) ? "30d" : range; - return switch (normalized) { - case "7d" -> 7; - case "90d" -> 90; - case "365d", "1y" -> 365; - default -> 30; - }; - } - - public String normalizeAllFilter(String value) { - return "all".equalsIgnoreCase(value) ? null : value; - } -} +package gg.modl.backend.infrastructure.util; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import java.util.Date; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class DateRangeUtil { + + private static final long DAY_MS = 24 * 60 * 60 * 1000L; + + public Date getStartDate(String period) { + if (period == null || "all".equals(period)) { + return null; + } + long now = System.currentTimeMillis(); + return switch (period) { + case "7d" -> new Date(now - 7 * DAY_MS); + case "90d" -> new Date(now - 90 * DAY_MS); + case "1y" -> new Date(now - 365 * DAY_MS); + default -> new Date(now - 30 * DAY_MS); + }; + } + + public Date daysAgo(int days) { + return new Date(System.currentTimeMillis() - days * DAY_MS); + } + + public Date parseEpochMillis(String value) { + if (value == null) { + return null; + } + try { + return new Date(Long.parseLong(value.trim())); + } catch (NumberFormatException exception) { + throw new ValidationException("Invalid date parameter; expected epoch milliseconds."); + } + } + + public int resolveRangeDays(String range) { + String normalized = (range == null || range.isBlank()) ? "30d" : range; + return switch (normalized) { + case "7d" -> 7; + case "90d" -> 90; + case "365d", "1y" -> 365; + default -> 30; + }; + } + + public String normalizeAllFilter(String value) { + return "all".equalsIgnoreCase(value) ? null : value; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java b/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java index 0ec1bf9..0ecad2d 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java @@ -1,28 +1,28 @@ -package gg.modl.backend.infrastructure.util; - -import java.security.SecureRandom; -import java.util.Base64; -import org.springframework.stereotype.Component; - -@Component -public class IdGenerator { - private static final SecureRandom RANDOM = new SecureRandom(); - - public int nextSixDigitInt() { - return 100000 + RANDOM.nextInt(900000); - } - - public String generateToken() { - byte[] bytes = new byte[32]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } - - public static String generateShortId() { - StringBuilder sb = new StringBuilder(8); - for (int i = 0; i < 8; i++) { - sb.append((char) ('A' + RANDOM.nextInt(26))); - } - return sb.toString(); - } -} +package gg.modl.backend.infrastructure.util; + +import java.security.SecureRandom; +import java.util.Base64; +import org.springframework.stereotype.Component; + +@Component +public class IdGenerator { + private static final SecureRandom RANDOM = new SecureRandom(); + + public int nextSixDigitInt() { + return 100000 + RANDOM.nextInt(900000); + } + + public String generateToken() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public static String generateShortId() { + StringBuilder sb = new StringBuilder(8); + for (int i = 0; i < 8; i++) { + sb.append((char) ('A' + RANDOM.nextInt(26))); + } + return sb.toString(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java b/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java index daf6800..00b559e 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/PaginationHelper.java @@ -1,38 +1,38 @@ -package gg.modl.backend.infrastructure.util; - -import java.util.List; -import lombok.experimental.UtilityClass; - -@UtilityClass -public class PaginationHelper { - - public int normalizeLimit(int limit, int maxLimit) { - return Math.min(maxLimit, Math.max(1, limit)); - } - - public PageResult paginate(List items, int page, int limit) { - int totalCount = items.size(); - int safeLimit = Math.max(0, limit); - int skip = calculateSkip(page, limit); - int from = Math.min(skip, totalCount); - int to = (int) Math.min((long) skip + safeLimit, totalCount); - List paged = items.subList(from, to); - boolean hasMore = (long) skip + safeLimit < totalCount; - return new PageResult<>(paged, totalCount, page, hasMore); - } - - public int calculateSkip(int page, int limit) { - long skip = (long) (normalizePage(page) - 1) * (long) Math.max(0, limit); - return (int) Math.min(skip, Integer.MAX_VALUE); - } - - public int normalizePage(int page) { - return Math.max(1, page); - } - - public int calculateTotalPages(long total, int limit) { - return (int) Math.ceil((double) total / limit); - } - - public record PageResult(List items, int totalCount, int page, boolean hasMore) {} -} +package gg.modl.backend.infrastructure.util; + +import java.util.List; +import lombok.experimental.UtilityClass; + +@UtilityClass +public class PaginationHelper { + + public int normalizeLimit(int limit, int maxLimit) { + return Math.min(maxLimit, Math.max(1, limit)); + } + + public PageResult paginate(List items, int page, int limit) { + int totalCount = items.size(); + int safeLimit = Math.max(0, limit); + int skip = calculateSkip(page, limit); + int from = Math.min(skip, totalCount); + int to = (int) Math.min((long) skip + safeLimit, totalCount); + List paged = items.subList(from, to); + boolean hasMore = (long) skip + safeLimit < totalCount; + return new PageResult<>(paged, totalCount, page, hasMore); + } + + public int calculateSkip(int page, int limit) { + long skip = (long) (normalizePage(page) - 1) * (long) Math.max(0, limit); + return (int) Math.min(skip, Integer.MAX_VALUE); + } + + public int normalizePage(int page) { + return Math.max(1, page); + } + + public int calculateTotalPages(long total, int limit) { + return (int) Math.ceil((double) total / limit); + } + + public record PageResult(List items, int totalCount, int page, boolean hasMore) {} +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java index 04fd50f..606286e 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java @@ -1,12 +1,12 @@ -package gg.modl.backend.infrastructure.util; - -import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; -import lombok.experimental.UtilityClass; -import org.jetbrains.annotations.Nullable; - -@UtilityClass -public class SafeConvertUtil { - public static int toInt(@Nullable Object value) { - return ProtoMapperSupport.intValueOrZero(value); - } -} +package gg.modl.backend.infrastructure.util; + +import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import lombok.experimental.UtilityClass; +import org.jetbrains.annotations.Nullable; + +@UtilityClass +public class SafeConvertUtil { + public static int toInt(@Nullable Object value) { + return ProtoMapperSupport.intValueOrZero(value); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java b/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java index 90c037b..101b76e 100644 --- a/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java +++ b/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java @@ -1,8 +1,8 @@ -package gg.modl.backend.infrastructure.validation; - -public final class RegExpConstants { - public static final String UUID = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; - public static final String MINECRAFT_USERNAME = "^[a-zA-Z0-9_.]{2,16}$"; - @Deprecated - public static final String IP = "^([0-9a-fA-F.:]+)$"; -} +package gg.modl.backend.infrastructure.validation; + +public final class RegExpConstants { + public static final String UUID = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; + public static final String MINECRAFT_USERNAME = "^[a-zA-Z0-9_.]{2,16}$"; + @Deprecated + public static final String IP = "^([0-9a-fA-F.:]+)$"; +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java b/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java index e46b3c2..2a2d8ab 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java @@ -1,184 +1,184 @@ -package gg.modl.backend.knowledgebase.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; -import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateArticleRequest; -import gg.modl.proto.modl.v1.CreateCategoryRequest; -import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; -import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; -import gg.modl.proto.modl.v1.KnowledgebaseCategoryResponse; -import gg.modl.proto.modl.v1.KnowledgebaseMessageResponse; -import gg.modl.proto.modl.v1.PanelKnowledgebaseCategoriesResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.ReorderRequest; -import gg.modl.proto.modl.v1.UpdateArticleRequest; -import gg.modl.proto.modl.v1.UpdateCategoryRequest; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Collections; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE) -@RequiredArgsConstructor -public class PanelKnowledgebaseController { - private final KnowledgebaseCategoryService categoryService; - private final KnowledgebaseArticleService articleService; - private final KnowledgebaseProtoMapper mapper; - private final RealtimeEventPublisher publisher; - - @GetMapping("/categories") - public ResponseEntity getCategories(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - var categories = categoryService.getAllCategories(server); - Map> articlesByCategory = - articleService.getAllArticlesGroupedByCategory(server); - - return ResponseEntity.ok(mapper.toPanelCategoriesResponse( - categories, - category -> articlesByCategory.getOrDefault(category.getId(), Collections.emptyList()))); - } - - @PostMapping("/categories") - public ResponseEntity createCategory( - @RequestBody CreateCategoryRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - var category = categoryService.createCategory(server, createRequest); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toCategoryResponse(category)); - } - - @PutMapping("/categories/{id}") - public ResponseEntity updateCategory( - @PathVariable String id, - @RequestBody UpdateCategoryRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return categoryService.updateCategory(server, id, updateRequest) - .map(category -> { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); - return ResponseEntity.ok(mapper.toCategoryResponse(category)); - }) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @DeleteMapping("/categories/{id}") - public ResponseEntity deleteCategory( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean deleted = categoryService.deleteCategory(server, id); - if (deleted) { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, id); - return ResponseEntity.ok(mapper.message("Category deleted")); - } - return ResponseEntity.notFound().build(); - } - - @PutMapping("/categories/reorder") - public ResponseEntity reorderCategories( - @RequestBody ReorderRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - categoryService.reorderCategories(server, reorderRequest.getIdsList()); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); - return ResponseEntity.ok(mapper.message("Categories reordered")); - } - - @GetMapping("/categories/{categoryId}/articles") - public ResponseEntity getArticles( - @PathVariable String categoryId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toArticlesResponse( - articleService.getArticlesByCategory(server, categoryId))); - } - - @GetMapping("/categories/{categoryId}/articles/{articleId}") - public ResponseEntity getArticle( - @PathVariable String categoryId, - @PathVariable String articleId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return articleService.getArticleById(server, articleId) - .map(article -> ResponseEntity.ok(mapper.toArticleResponse(article))) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @PostMapping("/categories/{categoryId}/articles") - public ResponseEntity createArticle( - @PathVariable String categoryId, - @RequestBody CreateArticleRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - KnowledgebaseArticle article = articleService.createArticle(server, categoryId, createRequest); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); - return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toArticleResponse(article)); - } - - @PutMapping("/categories/{categoryId}/articles/{articleId}") - public ResponseEntity updateArticle( - @PathVariable String categoryId, - @PathVariable String articleId, - @RequestBody UpdateArticleRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return articleService.updateArticle(server, articleId, updateRequest) - .map(article -> { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); - return ResponseEntity.ok(mapper.toArticleResponse(article)); - }) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @DeleteMapping("/categories/{categoryId}/articles/{articleId}") - public ResponseEntity deleteArticle( - @PathVariable String categoryId, - @PathVariable String articleId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean deleted = articleService.deleteArticle(server, articleId); - if (deleted) { - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, articleId); - return ResponseEntity.ok(mapper.message("Article deleted")); - } - return ResponseEntity.notFound().build(); - } - - @PutMapping("/categories/{categoryId}/articles/reorder") - public ResponseEntity reorderArticles( - @PathVariable String categoryId, - @RequestBody ReorderRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - articleService.reorderArticles(server, categoryId, reorderRequest.getIdsList()); - publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); - return ResponseEntity.ok(mapper.message("Articles reordered")); - } -} +package gg.modl.backend.knowledgebase.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; +import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateArticleRequest; +import gg.modl.proto.modl.v1.CreateCategoryRequest; +import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; +import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; +import gg.modl.proto.modl.v1.KnowledgebaseCategoryResponse; +import gg.modl.proto.modl.v1.KnowledgebaseMessageResponse; +import gg.modl.proto.modl.v1.PanelKnowledgebaseCategoriesResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.ReorderRequest; +import gg.modl.proto.modl.v1.UpdateArticleRequest; +import gg.modl.proto.modl.v1.UpdateCategoryRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE) +@RequiredArgsConstructor +public class PanelKnowledgebaseController { + private final KnowledgebaseCategoryService categoryService; + private final KnowledgebaseArticleService articleService; + private final KnowledgebaseProtoMapper mapper; + private final RealtimeEventPublisher publisher; + + @GetMapping("/categories") + public ResponseEntity getCategories(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + var categories = categoryService.getAllCategories(server); + Map> articlesByCategory = + articleService.getAllArticlesGroupedByCategory(server); + + return ResponseEntity.ok(mapper.toPanelCategoriesResponse( + categories, + category -> articlesByCategory.getOrDefault(category.getId(), Collections.emptyList()))); + } + + @PostMapping("/categories") + public ResponseEntity createCategory( + @RequestBody CreateCategoryRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + var category = categoryService.createCategory(server, createRequest); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toCategoryResponse(category)); + } + + @PutMapping("/categories/{id}") + public ResponseEntity updateCategory( + @PathVariable String id, + @RequestBody UpdateCategoryRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return categoryService.updateCategory(server, id, updateRequest) + .map(category -> { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, category.getId()); + return ResponseEntity.ok(mapper.toCategoryResponse(category)); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/categories/{id}") + public ResponseEntity deleteCategory( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean deleted = categoryService.deleteCategory(server, id); + if (deleted) { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, id); + return ResponseEntity.ok(mapper.message("Category deleted")); + } + return ResponseEntity.notFound().build(); + } + + @PutMapping("/categories/reorder") + public ResponseEntity reorderCategories( + @RequestBody ReorderRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + categoryService.reorderCategories(server, reorderRequest.getIdsList()); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); + return ResponseEntity.ok(mapper.message("Categories reordered")); + } + + @GetMapping("/categories/{categoryId}/articles") + public ResponseEntity getArticles( + @PathVariable String categoryId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toArticlesResponse( + articleService.getArticlesByCategory(server, categoryId))); + } + + @GetMapping("/categories/{categoryId}/articles/{articleId}") + public ResponseEntity getArticle( + @PathVariable String categoryId, + @PathVariable String articleId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return articleService.getArticleById(server, articleId) + .map(article -> ResponseEntity.ok(mapper.toArticleResponse(article))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping("/categories/{categoryId}/articles") + public ResponseEntity createArticle( + @PathVariable String categoryId, + @RequestBody CreateArticleRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + KnowledgebaseArticle article = articleService.createArticle(server, categoryId, createRequest); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(mapper.toArticleResponse(article)); + } + + @PutMapping("/categories/{categoryId}/articles/{articleId}") + public ResponseEntity updateArticle( + @PathVariable String categoryId, + @PathVariable String articleId, + @RequestBody UpdateArticleRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return articleService.updateArticle(server, articleId, updateRequest) + .map(article -> { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, article.getId()); + return ResponseEntity.ok(mapper.toArticleResponse(article)); + }) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @DeleteMapping("/categories/{categoryId}/articles/{articleId}") + public ResponseEntity deleteArticle( + @PathVariable String categoryId, + @PathVariable String articleId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean deleted = articleService.deleteArticle(server, articleId); + if (deleted) { + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE, articleId); + return ResponseEntity.ok(mapper.message("Article deleted")); + } + return ResponseEntity.notFound().build(); + } + + @PutMapping("/categories/{categoryId}/articles/reorder") + public ResponseEntity reorderArticles( + @PathVariable String categoryId, + @RequestBody ReorderRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + articleService.reorderArticles(server, categoryId, reorderRequest.getIdsList()); + publisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_KNOWLEDGEBASE); + return ResponseEntity.ok(mapper.message("Articles reordered")); + } +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java b/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java index ecf2321..3a11db2 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/PublicKnowledgebaseController.java @@ -1,76 +1,76 @@ -package gg.modl.backend.knowledgebase.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; -import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; -import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; -import gg.modl.proto.modl.v1.KnowledgebaseSearchResponse; -import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoriesResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_KNOWLEDGEBASE) -@RequiredArgsConstructor -public class PublicKnowledgebaseController { - private final KnowledgebaseCategoryService categoryService; - private final KnowledgebaseArticleService articleService; - private final KnowledgebaseProtoMapper mapper; - - @GetMapping("/categories") - public ResponseEntity getCategories(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - var categories = categoryService.getVisibleCategories(server); - return ResponseEntity.ok(mapper.toPublicCategoriesResponse( - categories, - category -> articleService.getVisibleArticlesByCategory(server, category.getId()))); - } - - @GetMapping("/categories/{categoryId}/articles") - public ResponseEntity getArticles( - @PathVariable String categoryId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toArticlesResponse( - articleService.getVisibleArticlesByCategory(server, categoryId))); - } - - @GetMapping("/articles/{idOrSlug}") - public ResponseEntity getArticle( - @PathVariable String idOrSlug, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Optional article = articleService.getArticleById(server, idOrSlug); - if (article.isEmpty()) { - article = articleService.getArticleBySlug(server, idOrSlug); - } - - return article - .filter(KnowledgebaseArticle::isVisible) - .map(found -> ResponseEntity.ok(mapper.toArticleResponse(found))) - .orElseGet(() -> ResponseEntity.notFound().build()); - } - - @GetMapping("/search") - public ResponseEntity searchArticles( - @RequestParam String q, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(mapper.toSearchResponse(articleService.searchArticles(server, q))); - } -} +package gg.modl.backend.knowledgebase.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.service.KnowledgebaseArticleService; +import gg.modl.backend.knowledgebase.service.KnowledgebaseCategoryService; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.KnowledgebaseArticleResponse; +import gg.modl.proto.modl.v1.KnowledgebaseArticlesResponse; +import gg.modl.proto.modl.v1.KnowledgebaseSearchResponse; +import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoriesResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_KNOWLEDGEBASE) +@RequiredArgsConstructor +public class PublicKnowledgebaseController { + private final KnowledgebaseCategoryService categoryService; + private final KnowledgebaseArticleService articleService; + private final KnowledgebaseProtoMapper mapper; + + @GetMapping("/categories") + public ResponseEntity getCategories(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + var categories = categoryService.getVisibleCategories(server); + return ResponseEntity.ok(mapper.toPublicCategoriesResponse( + categories, + category -> articleService.getVisibleArticlesByCategory(server, category.getId()))); + } + + @GetMapping("/categories/{categoryId}/articles") + public ResponseEntity getArticles( + @PathVariable String categoryId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toArticlesResponse( + articleService.getVisibleArticlesByCategory(server, categoryId))); + } + + @GetMapping("/articles/{idOrSlug}") + public ResponseEntity getArticle( + @PathVariable String idOrSlug, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Optional article = articleService.getArticleById(server, idOrSlug); + if (article.isEmpty()) { + article = articleService.getArticleBySlug(server, idOrSlug); + } + + return article + .filter(KnowledgebaseArticle::isVisible) + .map(found -> ResponseEntity.ok(mapper.toArticleResponse(found))) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @GetMapping("/search") + public ResponseEntity searchArticles( + @RequestParam String q, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(mapper.toSearchResponse(articleService.searchArticles(server, q))); + } +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java b/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java index 56cb30a..98389cd 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java +++ b/src/main/java/gg/modl/backend/knowledgebase/data/KnowledgebaseCategory.java @@ -1,39 +1,39 @@ -package gg.modl.backend.knowledgebase.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = CollectionName.KNOWLEDGEBASE_CATEGORIES) -@GenerateMongoFields -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class KnowledgebaseCategory { - @Id - private String id; - - @Field("name") - private String name; - @Field("slug") - private String slug; - @Field("description") - private String description; - @Field("ordinal") - private int ordinal; - @Field("isVisible") - private boolean isVisible; - - @Field("createdAt") - private Date createdAt; - @Field("updatedAt") - private Date updatedAt; -} +package gg.modl.backend.knowledgebase.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = CollectionName.KNOWLEDGEBASE_CATEGORIES) +@GenerateMongoFields +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KnowledgebaseCategory { + @Id + private String id; + + @Field("name") + private String name; + @Field("slug") + private String slug; + @Field("description") + private String description; + @Field("ordinal") + private int ordinal; + @Field("isVisible") + private boolean isVisible; + + @Field("createdAt") + private Date createdAt; + @Field("updatedAt") + private Date updatedAt; +} diff --git a/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java b/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java index fae5e57..9af5970 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java +++ b/src/main/java/gg/modl/backend/knowledgebase/service/KnowledgebaseArticleService.java @@ -1,110 +1,110 @@ -package gg.modl.backend.knowledgebase.service; - -import com.github.slugify.Slugify; -import gg.modl.backend.database.mongo.repository.KnowledgebaseArticleMongoRepository; -import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.CreateArticleRequest; -import gg.modl.proto.modl.v1.UpdateArticleRequest; -import java.util.Collection; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class KnowledgebaseArticleService { - private final KnowledgebaseArticleMongoRepository articleRepository; - private final Slugify slugify = Slugify.builder().build(); - private static final int MAX_SEARCH_RESULTS = 20; - - public List getArticlesByCategory(Server server, String categoryId) { - return articleRepository.findByCategoryOrdered(server, categoryId); - } - - public Map> getAllArticlesGroupedByCategory(Server server) { - return articleRepository.findAll(server) - .stream() - .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); - } - - public List getVisibleArticlesByCategory(Server server, String categoryId) { - return articleRepository.findVisibleByCategoryOrdered(server, categoryId); - } - - public Map> getVisibleArticlesGroupedByCategoryIds(Server server, Collection categoryIds) { - return articleRepository.findVisibleByCategoryIdsOrdered(server, categoryIds) - .stream() - .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); - } - - public Optional getArticleById(Server server, String id) { - return articleRepository.findByArticleId(server, id); - } - - public Optional getArticleBySlug(Server server, String slug) { - return articleRepository.findBySlug(server, slug); - } - - public KnowledgebaseArticle createArticle(Server server, String categoryId, CreateArticleRequest request) { - KnowledgebaseArticle article = KnowledgebaseArticle.builder() - .title(request.getTitle()) - .slug(generateUniqueSlug(server, slugify.slugify(request.getTitle()), null)) - .content(request.getContent()) - .categoryId(categoryId) - .ordinal(articleRepository.findMaxOrdinalInCategory(server, categoryId) + 1) - .isVisible(!request.hasIsVisible() || request.getIsVisible()) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - return articleRepository.saveEntity(server, article); - } - - private String generateUniqueSlug(Server server, String baseSlug, String excludeId) { - String slug = baseSlug; - int suffix = 1; - - while (articleRepository.existsBySlug(server, slug, excludeId)) { - slug = baseSlug + "-" + suffix; - suffix++; - } - - return slug; - } - - public Optional updateArticle(Server server, String id, UpdateArticleRequest request) { - String title = request.hasTitle() ? request.getTitle() : null; - String uniqueSlug = title != null - ? generateUniqueSlug(server, slugify.slugify(title), id) - : null; - - return articleRepository.updateArticle( - server, - id, - title, - uniqueSlug, - request.hasContent() ? request.getContent() : null, - request.hasIsVisible() ? request.getIsVisible() : null, - new Date() - ); - } - - public boolean deleteArticle(Server server, String id) { - return articleRepository.deleteByArticleId(server, id); - } - - public List searchArticles(Server server, String searchQuery) { - return articleRepository.searchVisibleArticles(server, searchQuery, MAX_SEARCH_RESULTS); - } - - public void reorderArticles(Server server, String categoryId, List ids) { - articleRepository.reorderArticles(server, categoryId, ids); - } -} +package gg.modl.backend.knowledgebase.service; + +import com.github.slugify.Slugify; +import gg.modl.backend.database.mongo.repository.KnowledgebaseArticleMongoRepository; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.CreateArticleRequest; +import gg.modl.proto.modl.v1.UpdateArticleRequest; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class KnowledgebaseArticleService { + private final KnowledgebaseArticleMongoRepository articleRepository; + private final Slugify slugify = Slugify.builder().build(); + private static final int MAX_SEARCH_RESULTS = 20; + + public List getArticlesByCategory(Server server, String categoryId) { + return articleRepository.findByCategoryOrdered(server, categoryId); + } + + public Map> getAllArticlesGroupedByCategory(Server server) { + return articleRepository.findAll(server) + .stream() + .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); + } + + public List getVisibleArticlesByCategory(Server server, String categoryId) { + return articleRepository.findVisibleByCategoryOrdered(server, categoryId); + } + + public Map> getVisibleArticlesGroupedByCategoryIds(Server server, Collection categoryIds) { + return articleRepository.findVisibleByCategoryIdsOrdered(server, categoryIds) + .stream() + .collect(Collectors.groupingBy(KnowledgebaseArticle::getCategoryId)); + } + + public Optional getArticleById(Server server, String id) { + return articleRepository.findByArticleId(server, id); + } + + public Optional getArticleBySlug(Server server, String slug) { + return articleRepository.findBySlug(server, slug); + } + + public KnowledgebaseArticle createArticle(Server server, String categoryId, CreateArticleRequest request) { + KnowledgebaseArticle article = KnowledgebaseArticle.builder() + .title(request.getTitle()) + .slug(generateUniqueSlug(server, slugify.slugify(request.getTitle()), null)) + .content(request.getContent()) + .categoryId(categoryId) + .ordinal(articleRepository.findMaxOrdinalInCategory(server, categoryId) + 1) + .isVisible(!request.hasIsVisible() || request.getIsVisible()) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + return articleRepository.saveEntity(server, article); + } + + private String generateUniqueSlug(Server server, String baseSlug, String excludeId) { + String slug = baseSlug; + int suffix = 1; + + while (articleRepository.existsBySlug(server, slug, excludeId)) { + slug = baseSlug + "-" + suffix; + suffix++; + } + + return slug; + } + + public Optional updateArticle(Server server, String id, UpdateArticleRequest request) { + String title = request.hasTitle() ? request.getTitle() : null; + String uniqueSlug = title != null + ? generateUniqueSlug(server, slugify.slugify(title), id) + : null; + + return articleRepository.updateArticle( + server, + id, + title, + uniqueSlug, + request.hasContent() ? request.getContent() : null, + request.hasIsVisible() ? request.getIsVisible() : null, + new Date() + ); + } + + public boolean deleteArticle(Server server, String id) { + return articleRepository.deleteByArticleId(server, id); + } + + public List searchArticles(Server server, String searchQuery) { + return articleRepository.searchVisibleArticles(server, searchQuery, MAX_SEARCH_RESULTS); + } + + public void reorderArticles(Server server, String categoryId, List ids) { + articleRepository.reorderArticles(server, categoryId, ids); + } +} diff --git a/src/main/java/gg/modl/backend/log/data/SystemLog.java b/src/main/java/gg/modl/backend/log/data/SystemLog.java index 9d10afd..c6e8f9c 100644 --- a/src/main/java/gg/modl/backend/log/data/SystemLog.java +++ b/src/main/java/gg/modl/backend/log/data/SystemLog.java @@ -1,36 +1,36 @@ -package gg.modl.backend.log.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@GenerateMongoFields(className = "ServerLogFields") -public class SystemLog { - @Id - private String id; - - @Field("description") - private String description; - - @Field("level") - @Builder.Default - private String level = "info"; - - @Field("source") - @Builder.Default - private String source = "system"; - - @Field("created") - private Date created; -} +package gg.modl.backend.log.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@GenerateMongoFields(className = "ServerLogFields") +public class SystemLog { + @Id + private String id; + + @Field("description") + private String description; + + @Field("level") + @Builder.Default + private String level = "info"; + + @Field("source") + @Builder.Default + private String source = "system"; + + @Field("created") + private Date created; +} diff --git a/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java b/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java index 8c08652..3ad76cb 100644 --- a/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java +++ b/src/main/java/gg/modl/backend/log/dto/response/SystemLogResponse.java @@ -1,11 +1,11 @@ -package gg.modl.backend.log.dto.response; - -import java.util.Date; - -public record SystemLogResponse( - String id, - String description, - String level, - String source, - Date created -) {} +package gg.modl.backend.log.dto.response; + +import java.util.Date; + +public record SystemLogResponse( + String id, + String description, + String level, + String source, + Date created +) {} diff --git a/src/main/java/gg/modl/backend/log/service/LogService.java b/src/main/java/gg/modl/backend/log/service/LogService.java index a500d24..7fbb718 100644 --- a/src/main/java/gg/modl/backend/log/service/LogService.java +++ b/src/main/java/gg/modl/backend/log/service/LogService.java @@ -1,60 +1,60 @@ -package gg.modl.backend.log.service; - -import gg.modl.backend.database.mongo.repository.ServerLogMongoRepository; -import gg.modl.backend.log.data.SystemLog; -import gg.modl.backend.log.dto.response.SystemLogResponse; -import gg.modl.backend.server.data.Server; -import java.util.Date; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class LogService { - private final ServerLogMongoRepository serverLogRepository; - - private static final int MAX_LIMIT = 500; - private static final String LEVEL_MODERATION = "moderation"; - private static final String LEVEL_INFO = "info"; - private static final String SOURCE_SYSTEM = "system"; - - public List getLogs(Server server, int limit) { - int safeLimit = Math.max(1, Math.min(limit, MAX_LIMIT)); - List logs = serverLogRepository.findRecent(server, safeLimit); - - return logs.stream() - .map(l -> new SystemLogResponse( - l.getId(), - l.getDescription(), - l.getLevel(), - l.getSource(), - l.getCreated() - )) - .toList(); - } - - public void recordModerationAction(Server server, String source, String description) { - write(server, description, LEVEL_MODERATION, source); - } - - public void recordStaffAction(Server server, String source, String description) { - write(server, description, LEVEL_INFO, source); - } - - private void write(Server server, String description, String level, String source) { - SystemLog entry = SystemLog.builder() - .description(description) - .level(level) - .source(source != null && !source.isBlank() ? source : SOURCE_SYSTEM) - .created(new Date()) - .build(); - try { - serverLogRepository.saveEntity(server, entry); - } catch (RuntimeException e) { - log.error("Failed to record audit log entry [{}] for server {}", description, server.getDatabaseName(), e); - } - } -} +package gg.modl.backend.log.service; + +import gg.modl.backend.database.mongo.repository.ServerLogMongoRepository; +import gg.modl.backend.log.data.SystemLog; +import gg.modl.backend.log.dto.response.SystemLogResponse; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class LogService { + private final ServerLogMongoRepository serverLogRepository; + + private static final int MAX_LIMIT = 500; + private static final String LEVEL_MODERATION = "moderation"; + private static final String LEVEL_INFO = "info"; + private static final String SOURCE_SYSTEM = "system"; + + public List getLogs(Server server, int limit) { + int safeLimit = Math.max(1, Math.min(limit, MAX_LIMIT)); + List logs = serverLogRepository.findRecent(server, safeLimit); + + return logs.stream() + .map(l -> new SystemLogResponse( + l.getId(), + l.getDescription(), + l.getLevel(), + l.getSource(), + l.getCreated() + )) + .toList(); + } + + public void recordModerationAction(Server server, String source, String description) { + write(server, description, LEVEL_MODERATION, source); + } + + public void recordStaffAction(Server server, String source, String description) { + write(server, description, LEVEL_INFO, source); + } + + private void write(Server server, String description, String level, String source) { + SystemLog entry = SystemLog.builder() + .description(description) + .level(level) + .source(source != null && !source.isBlank() ? source : SOURCE_SYSTEM) + .created(new Date()) + .build(); + try { + serverLogRepository.saveEntity(server, entry); + } catch (RuntimeException e) { + log.error("Failed to record audit log entry [{}] for server {}", description, server.getDatabaseName(), e); + } + } +} diff --git a/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java b/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java index 1c86d0d..0767a87 100644 --- a/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java +++ b/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java @@ -1,95 +1,95 @@ -package gg.modl.backend.migration.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.migration.dto.UpdateProgressRequest; -import gg.modl.backend.migration.service.MigrationProcessor; -import gg.modl.backend.migration.service.MigrationService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import java.nio.file.Path; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.core.task.TaskRejectedException; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_MIGRATION) -@RequiredArgsConstructor -public class MinecraftMigrationController { - private final MigrationService migrationService; - private final MigrationProcessor migrationProcessor; - - @PostMapping("/upload") - public ResponseEntity uploadMigrationFile( - @RequestParam("migrationFile") MultipartFile file, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (file.isEmpty()) { - throw new ValidationException("No file uploaded"); - } - - String originalFilename = file.getOriginalFilename(); - if (originalFilename == null || !originalFilename.endsWith(".json")) { - throw new ValidationException("Only JSON files are allowed"); - } - - Map sizeError = migrationService.validateFileSize(server, file); - if (sizeError != null) { - return ResponseEntity.status(413).body(sizeError); - } - - migrationService.requireActiveMigrationForUpload(server); - - Path filePath = migrationService.saveUploadedFile(file); - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "uploading_json", - "Migration file uploaded successfully. Starting data processing...", - 0, 0, null - )); - migrationProcessor.processFileAsync(server, filePath); - } catch (TaskRejectedException e) { - migrationService.discardUpload(server, filePath, - "Migration processing is busy. Please try again shortly."); - return ResponseEntity.status(503).body(Map.of( - "error", "Migration processing is busy", - "message", "The server is processing other migrations. Please try again shortly." - )); - } catch (RuntimeException e) { - migrationService.discardUpload(server, filePath, "Migration failed to start."); - return ResponseEntity.status(503).body(Map.of( - "error", "Migration failed to start", - "message", "The migration could not be started. Please try again." - )); - } - - return ResponseEntity.ok(Map.of( - "success", true, - "message", "Migration file uploaded successfully. Processing started.", - "fileSize", file.getSize() - )); - } - - @PostMapping("/progress") - public ResponseEntity updateProgress( - @RequestBody @Valid UpdateProgressRequest progressRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - migrationService.updateProgress(server, progressRequest); - - return ResponseEntity.ok(Map.of("success", true)); - } -} +package gg.modl.backend.migration.controller; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.migration.service.MigrationProcessor; +import gg.modl.backend.migration.service.MigrationService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import java.nio.file.Path; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.core.task.TaskRejectedException; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_MIGRATION) +@RequiredArgsConstructor +public class MinecraftMigrationController { + private final MigrationService migrationService; + private final MigrationProcessor migrationProcessor; + + @PostMapping("/upload") + public ResponseEntity uploadMigrationFile( + @RequestParam("migrationFile") MultipartFile file, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + if (file.isEmpty()) { + throw new ValidationException("No file uploaded"); + } + + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || !originalFilename.endsWith(".json")) { + throw new ValidationException("Only JSON files are allowed"); + } + + Map sizeError = migrationService.validateFileSize(server, file); + if (sizeError != null) { + return ResponseEntity.status(413).body(sizeError); + } + + migrationService.requireActiveMigrationForUpload(server); + + Path filePath = migrationService.saveUploadedFile(file); + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "uploading_json", + "Migration file uploaded successfully. Starting data processing...", + 0, 0, null + )); + migrationProcessor.processFileAsync(server, filePath); + } catch (TaskRejectedException e) { + migrationService.discardUpload(server, filePath, + "Migration processing is busy. Please try again shortly."); + return ResponseEntity.status(503).body(Map.of( + "error", "Migration processing is busy", + "message", "The server is processing other migrations. Please try again shortly." + )); + } catch (RuntimeException e) { + migrationService.discardUpload(server, filePath, "Migration failed to start."); + return ResponseEntity.status(503).body(Map.of( + "error", "Migration failed to start", + "message", "The migration could not be started. Please try again." + )); + } + + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Migration file uploaded successfully. Processing started.", + "fileSize", file.getSize() + )); + } + + @PostMapping("/progress") + public ResponseEntity updateProgress( + @RequestBody @Valid UpdateProgressRequest progressRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + migrationService.updateProgress(server, progressRequest); + + return ResponseEntity.ok(Map.of("success", true)); + } +} diff --git a/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java b/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java index 3a70993..cccdce5 100644 --- a/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java +++ b/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java @@ -1,62 +1,62 @@ -package gg.modl.backend.migration.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.migration.service.MigrationService; -import gg.modl.backend.migration.service.MigrationService.CooldownState; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.MigrationOperationResponse; -import gg.modl.proto.modl.v1.MigrationStatusResponse; -import gg.modl.proto.modl.v1.StartMigrationRequest; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_MIGRATION) -@RequiredArgsConstructor -public class PanelMigrationController { - private final MigrationService migrationService; - private final MigrationProtoMapper mapper; - - @GetMapping("/status") - public ResponseEntity getMigrationStatus(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - CooldownState cooldown = migrationService.checkCooldown(server); - return ResponseEntity.ok(mapper.toStatusResponse( - migrationService.getLatestMigration(server).orElse(null), - cooldown.onCooldown(), - cooldown.remainingTime())); - } - - @PostMapping("/start") - public ResponseEntity startMigration( - @RequestBody StartMigrationRequest startRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - Map result = migrationService.startMigration(server, startRequest.getMigrationType()); - return toResponse(result); - } - - @PostMapping("/cancel") - public ResponseEntity cancelMigration(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - Map result = migrationService.cancelMigration(server); - return toResponse(result); - } - - private ResponseEntity toResponse(Map result) { - MigrationOperationResponse response = mapper.toOperationResponse(result); - if (Boolean.FALSE.equals(result.get("success"))) { - return ResponseEntity.badRequest().body(response); - } - return ResponseEntity.ok(response); - } -} +package gg.modl.backend.migration.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.migration.service.MigrationService; +import gg.modl.backend.migration.service.MigrationService.CooldownState; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.MigrationOperationResponse; +import gg.modl.proto.modl.v1.MigrationStatusResponse; +import gg.modl.proto.modl.v1.StartMigrationRequest; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_MIGRATION) +@RequiredArgsConstructor +public class PanelMigrationController { + private final MigrationService migrationService; + private final MigrationProtoMapper mapper; + + @GetMapping("/status") + public ResponseEntity getMigrationStatus(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + CooldownState cooldown = migrationService.checkCooldown(server); + return ResponseEntity.ok(mapper.toStatusResponse( + migrationService.getLatestMigration(server).orElse(null), + cooldown.onCooldown(), + cooldown.remainingTime())); + } + + @PostMapping("/start") + public ResponseEntity startMigration( + @RequestBody StartMigrationRequest startRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + Map result = migrationService.startMigration(server, startRequest.getMigrationType()); + return toResponse(result); + } + + @PostMapping("/cancel") + public ResponseEntity cancelMigration(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + Map result = migrationService.cancelMigration(server); + return toResponse(result); + } + + private ResponseEntity toResponse(Map result) { + MigrationOperationResponse response = mapper.toOperationResponse(result); + if (Boolean.FALSE.equals(result.get("success"))) { + return ResponseEntity.badRequest().body(response); + } + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java b/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java index b02ce41..1675610 100644 --- a/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java +++ b/src/main/java/gg/modl/backend/migration/data/MigrationStatus.java @@ -1,62 +1,62 @@ -package gg.modl.backend.migration.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document(collection = "migrations") -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "PROGRESS_MESSAGE", path = "progress.message"), - @MongoFieldAlias(name = "PROGRESS_RECORDS_PROCESSED", path = "progress.recordsProcessed"), - @MongoFieldAlias(name = "PROGRESS_RECORDS_SKIPPED", path = "progress.recordsSkipped"), - @MongoFieldAlias(name = "PROGRESS_TOTAL_RECORDS", path = "progress.totalRecords") -}) -public class MigrationStatus { - @Id - private String id; - - @Field("taskId") - private String taskId; - @Field("type") - private String type; - @Field("status") - private String status; - @Field("progress") - private MigrationProgress progress; - @Field("startedAt") - private Date startedAt; - @Field("completedAt") - private Date completedAt; - @Field("error") - private String error; - @Field("cooldownExempt") - private Boolean cooldownExempt; - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class MigrationProgress { - @Field("message") - private String message; - @Field("recordsProcessed") - private Integer recordsProcessed; - @Field("recordsSkipped") - private Integer recordsSkipped; - @Field("totalRecords") - private Integer totalRecords; - } -} +package gg.modl.backend.migration.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document(collection = "migrations") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "PROGRESS_MESSAGE", path = "progress.message"), + @MongoFieldAlias(name = "PROGRESS_RECORDS_PROCESSED", path = "progress.recordsProcessed"), + @MongoFieldAlias(name = "PROGRESS_RECORDS_SKIPPED", path = "progress.recordsSkipped"), + @MongoFieldAlias(name = "PROGRESS_TOTAL_RECORDS", path = "progress.totalRecords") +}) +public class MigrationStatus { + @Id + private String id; + + @Field("taskId") + private String taskId; + @Field("type") + private String type; + @Field("status") + private String status; + @Field("progress") + private MigrationProgress progress; + @Field("startedAt") + private Date startedAt; + @Field("completedAt") + private Date completedAt; + @Field("error") + private String error; + @Field("cooldownExempt") + private Boolean cooldownExempt; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class MigrationProgress { + @Field("message") + private String message; + @Field("recordsProcessed") + private Integer recordsProcessed; + @Field("recordsSkipped") + private Integer recordsSkipped; + @Field("totalRecords") + private Integer totalRecords; + } +} diff --git a/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java b/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java index 4050087..af9e042 100644 --- a/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java +++ b/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java @@ -1,46 +1,46 @@ -package gg.modl.backend.migration.dto; - -import java.util.List; -import java.util.Map; - -public record MigrationPlayerData( - String minecraftUuid, - List usernames, - List notes, - List ipAddresses, - List punishments, - Map data -) { - public record UsernameData(String username, String date) {} - - public record NoteData(String text, String date, String issuerName) {} - - public record IPData( - String ipAddress, - String country, - String region, - String asn, - Boolean proxy, - Boolean hosting, - String firstLogin, - List logins - ) {} - - public record PunishmentData( - String id, - String type, - int typeOrdinal, - String reason, - String issued, - String issuerName, - Long duration, - String started, - List notes, - List evidence, - List attachedTicketIds, - List modifications, - Map data - ) {} - - public record PunishmentNoteData(String text, String issuerName, String date) {} -} +package gg.modl.backend.migration.dto; + +import java.util.List; +import java.util.Map; + +public record MigrationPlayerData( + String minecraftUuid, + List usernames, + List notes, + List ipAddresses, + List punishments, + Map data +) { + public record UsernameData(String username, String date) {} + + public record NoteData(String text, String date, String issuerName) {} + + public record IPData( + String ipAddress, + String country, + String region, + String asn, + Boolean proxy, + Boolean hosting, + String firstLogin, + List logins + ) {} + + public record PunishmentData( + String id, + String type, + int typeOrdinal, + String reason, + String issued, + String issuerName, + Long duration, + String started, + List notes, + List evidence, + List attachedTicketIds, + List modifications, + Map data + ) {} + + public record PunishmentNoteData(String text, String issuerName, String date) {} +} diff --git a/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java b/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java index 6cbbeb2..07fcc38 100644 --- a/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java +++ b/src/main/java/gg/modl/backend/migration/dto/UpdateProgressRequest.java @@ -1,13 +1,13 @@ -package gg.modl.backend.migration.dto; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import jakarta.validation.constraints.NotBlank; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record UpdateProgressRequest( - @NotBlank String status, - @NotBlank String message, - Integer recordsProcessed, - Integer recordsSkipped, - Integer totalRecords -) {} +package gg.modl.backend.migration.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record UpdateProgressRequest( + @NotBlank String status, + @NotBlank String message, + Integer recordsProcessed, + Integer recordsSkipped, + Integer totalRecords +) {} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java b/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java index 708bf60..578d71c 100644 --- a/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java +++ b/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java @@ -1,849 +1,849 @@ -package gg.modl.backend.migration.service; - -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.core.StreamReadConstraints; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.migration.dto.UpdateProgressRequest; -import gg.modl.backend.migration.validation.MigrationValidator; -import gg.modl.backend.player.PlayerDocumentIdGenerator; -import gg.modl.backend.player.data.IPEntry; -import gg.modl.backend.player.data.NoteEntry; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.UsernameEntry; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentEvidence; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.player.data.punishment.PunishmentStatus; -import gg.modl.backend.server.data.Server; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import gg.modl.backend.infrastructure.util.IdGenerator; -import gg.modl.backend.infrastructure.validation.SafeUrls; -import org.springframework.data.mongodb.core.query.Update; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MigrationProcessor { - private final PlayerMongoRepository playerRepository; - private final MigrationService migrationService; - private final MigrationValidator validator; - private final ObjectMapper objectMapper; - - private static final int BATCH_SIZE = 500; - private static final int PROGRESS_UPDATE_INTERVAL = 1000; - private static final int MAX_JSON_NESTING_DEPTH = 100; - private static final int MAX_JSON_STRING_LENGTH = 1_000_000; - private static final int MAX_FAILURE_MESSAGE_LENGTH = 900; - private static final String PLAYERS_FIELD = "players"; - private static final String METADATA_FIELD = "metadata"; - private static final String PLAYER_COUNT_FIELD = "playerCount"; - - @Async("migrationTaskExecutor") - public void processFileAsync(Server server, Path filePath) { - try { - processFile(server, filePath); - } catch (Exception e) { - log.error("Async migration processing failed", e); - } - } - - public void processFile(Server server, Path filePath) { - ProgressCounters counters = new ProgressCounters(); - - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "processing_data", - "Reading and validating migration file...", - 0, 0, null - )); - - ObjectMapper constrainedMapper = objectMapper.copy(); - constrainedMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() - .maxNestingDepth(MAX_JSON_NESTING_DEPTH) - .maxStringLength(MAX_JSON_STRING_LENGTH) - .build()); - - if (streamMigrationFile(server, filePath, constrainedMapper, counters)) { - return; - } - - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "completed", - "Migration completed successfully", - counters.processed(), counters.skipped(), counters.total() - )); - } catch (Exception e) { - log.error("Failed to persist terminal migration state (completed)", e); - } - - } catch (MigrationDataException e) { - failMigration(server, e.getMessage(), counters); - } catch (Exception e) { - log.error("Error processing migration file", e); - failMigration(server, boundFailureMessage("Migration failed: ", e.getMessage()), counters); - } finally { - try { - Files.deleteIfExists(filePath); - } catch (IOException e) { - log.warn("Failed to delete migration file: {}", filePath, e); - } - } - } - - private void failMigration(Server server, String message, ProgressCounters counters) { - try { - migrationService.updateProgress(server, new UpdateProgressRequest( - "failed", message, counters.processed(), counters.skipped(), null - )); - } catch (Exception e) { - log.error("Failed to persist terminal migration state (failed)", e); - } - } - - private boolean streamMigrationFile(Server server, Path filePath, ObjectMapper mapper, - ProgressCounters counters) throws IOException { - JsonFactory factory = mapper.getFactory(); - try (JsonParser parser = factory.createParser(filePath.toFile())) { - if (parser.nextToken() != JsonToken.START_OBJECT) { - throw new MigrationDataException("Migration data must be a JSON object"); - } - - boolean playersStreamed = false; - while (parser.nextToken() != JsonToken.END_OBJECT) { - String field = parser.currentName(); - parser.nextToken(); - - if (PLAYERS_FIELD.equals(field)) { - MigrationValidator.ValidationResult header = validator.validateHeader( - true, parser.currentToken() == JsonToken.START_ARRAY, counters.total()); - if (!header.valid()) { - throw new MigrationDataException(header.error()); - } - announceProcessingProgress(server, counters); - if (streamPlayers(server, parser, mapper, counters)) { - return true; - } - playersStreamed = true; - } else if (METADATA_FIELD.equals(field)) { - counters.total(readDeclaredPlayerCount(parser)); - } else { - parser.skipChildren(); - } - } - - if (!playersStreamed) { - throw new MigrationDataException( - validator.validateHeader(false, false, counters.total()).error()); - } - - return false; - } - } - - private boolean streamPlayers(Server server, JsonParser parser, ObjectMapper mapper, - ProgressCounters counters) throws IOException { - long seen = 0; - List> batch = new ArrayList<>(BATCH_SIZE); - - while (parser.nextToken() != JsonToken.END_ARRAY) { - seen++; - if (seen > MigrationValidator.MAX_PLAYER_RECORDS) { - throw new MigrationDataException("Players array exceeds maximum length of 1,000,000"); - } - - if (parser.currentToken() == JsonToken.START_OBJECT) { - batch.add(mapper.readValue(parser, Map.class)); - } else { - parser.skipChildren(); - counters.skipOne(); - } - - if (batch.size() >= BATCH_SIZE && drainBatch(server, batch, counters)) { - return true; - } - } - - if (seen == 0) { - throw new MigrationDataException("Players array cannot be empty"); - } - - return !batch.isEmpty() && drainBatch(server, batch, counters); - } - - private boolean drainBatch(Server server, List> batch, ProgressCounters counters) { - int[] results = processBatch(server, batch); - counters.addProcessed(results[0]); - counters.addSkipped(results[1]); - batch.clear(); - - if (counters.dueForAnnounce(PROGRESS_UPDATE_INTERVAL)) { - announceProcessingProgress(server, counters); - } - - if (!migrationService.isActiveMigrationPresent(server)) { - log.info("Migration cancelled or no longer active; stopping processing after {} records", - counters.processed()); - return true; - } - return false; - } - - private void announceProcessingProgress(Server server, ProgressCounters counters) { - migrationService.updateProgress(server, new UpdateProgressRequest( - "processing_data", - processingMessage(counters.processed(), counters.total()), - counters.processed(), counters.skipped(), counters.total() - )); - } - - private Integer readDeclaredPlayerCount(JsonParser parser) throws IOException { - if (parser.currentToken() != JsonToken.START_OBJECT) { - parser.skipChildren(); - return null; - } - Integer playerCount = null; - while (parser.nextToken() != JsonToken.END_OBJECT) { - String field = parser.currentName(); - parser.nextToken(); - if (PLAYER_COUNT_FIELD.equals(field) && parser.currentToken().isNumeric()) { - long value = parser.getValueAsLong(-1L); - playerCount = (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value)); - } else { - parser.skipChildren(); - } - } - return playerCount; - } - - private static String processingMessage(int processed, Integer total) { - if (total == null) { - return "Processing player records... (" + processed + ")"; - } - return "Processing player records... (" + processed + "/" + total + ")"; - } - - private static String boundFailureMessage(String prefix, String detail) { - String message = prefix + (detail == null ? "unknown error" : detail); - if (message.length() > MAX_FAILURE_MESSAGE_LENGTH) { - return message.substring(0, MAX_FAILURE_MESSAGE_LENGTH - 1) + "…"; - } - return message; - } - - private int[] processBatch(Server server, List> batch) { - int processed = 0; - int skipped = 0; - - List uuids = new ArrayList<>(); - Map> playerDataMap = new HashMap<>(); - - for (Map playerMap : batch) { - Object uuidObj = playerMap.get("minecraftUuid"); - if (uuidObj == null || !(uuidObj instanceof String)) { - skipped++; - continue; - } - - String uuid = validator.normalizeUuid((String) uuidObj); - if (!validator.isValidUuid(uuid)) { - skipped++; - continue; - } - - uuids.add(uuid); - playerDataMap.put(uuid, playerMap); - } - - if (uuids.isEmpty()) { - return new int[]{0, skipped}; - } - - List existingPlayers = playerRepository.findByMinecraftUuids(server, - uuids.stream().map(UUID::fromString).toList()); - Map existingMap = new HashMap<>(); - for (Player p : existingPlayers) { - existingMap.put(p.getMinecraftUuid().toString(), p); - } - - List toInsert = new ArrayList<>(); - Map mergeUpdates = new HashMap<>(); - - for (String uuid : uuids) { - try { - Map playerMap = playerDataMap.get(uuid); - Player existing = existingMap.get(uuid); - - if (existing != null) { - Update update = buildMergeUpdate(existing, playerMap); - if (update != null) { - mergeUpdates.put(UUID.fromString(uuid), update); - } - } else { - Player newPlayer = buildNewPlayer(uuid, playerMap); - if (newPlayer != null) { - toInsert.add(newPlayer); - } - } - processed++; - } catch (Exception e) { - log.warn("Error processing player {}", uuid, e); - skipped++; - } - } - - if (!toInsert.isEmpty()) { - playerRepository.insertAll(server, toInsert); - } - - if (!mergeUpdates.isEmpty()) { - playerRepository.bulkMergeByUuid(server, mergeUpdates); - } - - return new int[]{processed, skipped}; - } - - private Player buildNewPlayer(String uuid, Map data) { - try { - Object ipObj = data.get("ipAddresses") != null ? data.get("ipAddresses") : data.get("ipList"); - Player player = Player.builder() - .id(PlayerDocumentIdGenerator.generate()) - .minecraftUuid(UUID.fromString(uuid)) - .usernames(parseUsernames(data.get("usernames"))) - .notes(parseNotes(data.get("notes"))) - .ipAddresses(parseIpAddresses(ipObj)) - .punishments(parsePunishments(data.get("punishments"))) - .data(parseData(data.get("data"))) - .build(); - - return player; - } catch (Exception e) { - log.warn("Error building new player for UUID {}", uuid, e); - return null; - } - } - - private List parseIpAddresses(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - String ipAddress = (String) map.get("ipAddress"); - if (!validator.isValidIpAddress(ipAddress)) { - continue; - } - - Date firstLogin = validator.parseDate(map.get("firstLogin")); - if (firstLogin == null) { - firstLogin = new Date(); - } - - List logins = new ArrayList<>(); - Object loginsObj = map.get("logins"); - if (loginsObj instanceof List) { - for (Object loginObj : (List) loginsObj) { - Date login = validator.parseDate(loginObj); - if (login != null) { - logins.add(login); - } - } - } - - result.add(IPEntry.builder() - .ipAddress(ipAddress) - .country(validator.sanitizeString((String) map.get("country"), 100)) - .region(validator.sanitizeString((String) map.get("region"), 100)) - .asn(validator.sanitizeString((String) map.get("asn"), 100)) - .proxy(Boolean.TRUE.equals(map.get("proxy"))) - .hosting(Boolean.TRUE.equals(map.get("hosting"))) - .firstLogin(firstLogin) - .logins(logins) - .build()); - } - - return result; - } - - @SuppressWarnings("unchecked") - private Map parseData(Object data) { - if (data instanceof Map) { - Map result = new HashMap<>(); - for (Map.Entry entry : ((Map) data).entrySet()) { - if (entry.getKey() instanceof String) { - result.put((String) entry.getKey(), entry.getValue()); - } - } - return result; - } - return new HashMap<>(); - } - - private Update buildMergeUpdate(Player existing, Map newData) { - Update update = new Update(); - boolean hasChanges = false; - - List newUsernames = parseUsernames(newData.get("usernames")); - if (!newUsernames.isEmpty()) { - Set existingNames = new HashSet<>(); - if (existing.getUsernames() != null) { - for (UsernameEntry u : existing.getUsernames()) { - existingNames.add(u.username()); - } - } - List toAddUsernames = new ArrayList<>(); - for (UsernameEntry u : newUsernames) { - if (existingNames.add(u.username())) { - toAddUsernames.add(u); - } - } - if (!toAddUsernames.isEmpty()) { - update.push("usernames").each(toAddUsernames.toArray()); - hasChanges = true; - } - } - - List newNotes = parseNotes(newData.get("notes")); - if (!newNotes.isEmpty()) { - Set existingNoteIds = new HashSet<>(); - if (existing.getNotes() != null) { - for (NoteEntry note : existing.getNotes()) { - existingNoteIds.add(note.getId()); - } - } - List toAddNotes = new ArrayList<>(); - for (NoteEntry note : newNotes) { - if (existingNoteIds.add(note.getId())) { - toAddNotes.add(note); - } - } - if (!toAddNotes.isEmpty()) { - update.push("notes").each(toAddNotes.toArray()); - hasChanges = true; - } - } - - List newPunishments = parsePunishments(newData.get("punishments")); - if (!newPunishments.isEmpty()) { - Set existingIds = new HashSet<>(); - if (existing.getPunishments() != null) { - for (Punishment p : existing.getPunishments()) { - existingIds.add(p.getId()); - } - } - List toAddPunishments = new ArrayList<>(); - for (Punishment p : newPunishments) { - if (existingIds.add(p.getId())) { - toAddPunishments.add(p); - } - } - if (!toAddPunishments.isEmpty()) { - update.push("punishments").each(toAddPunishments.toArray()); - hasChanges = true; - } - } - - Object ipObj = newData.get("ipAddresses") != null ? newData.get("ipAddresses") : newData.get("ipList"); - List newIps = parseIpAddresses(ipObj); - if (!newIps.isEmpty()) { - Set existingIps = new HashSet<>(); - if (existing.getIpAddresses() != null) { - for (IPEntry ip : existing.getIpAddresses()) { - if (ip.getIpAddress() != null) { - existingIps.add(ip.getIpAddress()); - } - } - } - List ipsToAdd = new ArrayList<>(); - for (IPEntry ip : newIps) { - if (ip.getIpAddress() != null && existingIps.add(ip.getIpAddress())) { - ipsToAdd.add(ip); - } - } - if (!ipsToAdd.isEmpty()) { - update.push("ipAddresses").each(ipsToAdd.toArray()); - hasChanges = true; - } - } - - return hasChanges ? update : null; - } - - private List parseUsernames(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - String username = validator.sanitizeString((String) map.get("username"), 100); - Date date = validator.parseDate(map.get("date")); - - if (username != null && !username.isBlank() && date != null) { - result.add(new UsernameEntry(username, date)); - } - } - - return result; - } - - private List parseNotes(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - String text = validator.sanitizeString((String) map.get("text"), 5000); - Date date = validator.parseDate(map.get("date")); - String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); - - if (text != null && date != null && issuerName != null) { - String sourceId = validator.sanitizeString((String) map.get("id"), 100); - String noteId = (sourceId != null && !sourceId.isBlank()) - ? sourceId - : UUID.nameUUIDFromBytes( - (text + "|" + date.getTime() + "|" + issuerName).getBytes(StandardCharsets.UTF_8)) - .toString(); - result.add(new NoteEntry( - noteId, - text, - date, - issuerName, - null - )); - } - } - - return result; - } - - private List parsePunishments(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map map = (Map) item; - - Object idObj = map.get("id") != null ? map.get("id") : map.get("_id"); - String id = idObj instanceof String s ? s : null; - - Date issued = validator.parseDate(map.get("issued")); - if (issued == null) { - continue; - } - - String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); - if (issuerName == null) { - issuerName = "Unknown"; - } - - Object typeOrdinalObj = map.get("typeOrdinal"); - int typeOrdinal = 0; - if (typeOrdinalObj instanceof Number) { - typeOrdinal = ((Number) typeOrdinalObj).intValue(); - } else if (typeOrdinalObj instanceof String typeOrdinalString) { - try { - typeOrdinal = Integer.parseInt(typeOrdinalString); - } catch (NumberFormatException ignored) { - typeOrdinal = 0; - } - } - - List notes = new ArrayList<>(); - Object notesObj = map.get("notes"); - if (notesObj instanceof List) { - for (Object noteObj : (List) notesObj) { - if (noteObj instanceof Map) { - Map noteMap = (Map) noteObj; - String text = validator.sanitizeString((String) noteMap.get("text"), 5000); - Date date = validator.parseDate(noteMap.get("date")); - String noteIssuer = validator.sanitizeString((String) noteMap.get("issuerName"), 100); - - if (text != null && date != null) { - notes.add(new PunishmentNote(IdGenerator.generateShortId(), text, date, noteIssuer != null ? noteIssuer : "Unknown", null)); - } - } - } - } - - List evidence = parseEvidence(map.get("evidence")); - - List modifications = parseModifications(map.get("modifications")); - - List attachedTicketIds = new ArrayList<>(); - Object ticketIdsObj = map.get("attachedTicketIds"); - if (ticketIdsObj instanceof List) { - for (Object ticketId : (List) ticketIdsObj) { - if (ticketId instanceof String) { - attachedTicketIds.add((String) ticketId); - } - } - } - - Map punishmentData = new HashMap<>(); - Object dataObj = map.get("data"); - if (dataObj instanceof Map) { - for (Map.Entry entry : ((Map) dataObj).entrySet()) { - if (entry.getKey() instanceof String) { - punishmentData.put((String) entry.getKey(), entry.getValue()); - } - } - } - - String reason = validator.sanitizeString((String) map.get("reason"), 1000); - if (reason != null && !reason.isBlank()) { - punishmentData.put("reason", reason); - } - - Object durationObj = map.get("duration"); - if (durationObj instanceof Number) { - punishmentData.put("duration", ((Number) durationObj).longValue()); - } - - Date started = validator.parseDate(map.get("started")); - - if (id == null || id.isBlank()) { - id = "import-" + UUID.nameUUIDFromBytes( - (typeOrdinal + "|" + issued.getTime() + "|" + issuerName + "|" + (reason != null ? reason : "")) - .getBytes(StandardCharsets.UTF_8)).toString(); - } - - Object activeObj = punishmentData.get("active"); - Object pardonedBy = punishmentData.get("pardonedBy"); - boolean sourceInactive = Boolean.FALSE.equals(activeObj) - || (activeObj instanceof String activeStr && "false".equalsIgnoreCase(activeStr)) - || pardonedBy != null; - boolean alreadyPardoned = modifications.stream() - .anyMatch(m -> PunishmentModificationType.isPardon(m.type())); - if (sourceInactive && !alreadyPardoned) { - Date pardonDate = validator.parseDate(punishmentData.get("pardonedDate")); - if (pardonDate == null) { - pardonDate = validator.parseDate(punishmentData.get("removedAt")); - } - if (pardonDate == null) { - pardonDate = issued; - } - String pardonIssuer = pardonedBy instanceof String pardonStr ? pardonStr : "System"; - modifications.add(new PunishmentModification( - IdGenerator.generateShortId(), - PunishmentModificationType.SYSTEM_PARDON.name(), - pardonDate, - pardonIssuer, - null, - "Imported as already removed/inactive", - null, - null, - null - )); - punishmentData.put("status", PunishmentStatus.PARDONED); - } - - Punishment punishment = new Punishment( - id, - typeOrdinal, - issuerName, - null, - issued, - started, - modifications, - notes, - evidence, - attachedTicketIds, - punishmentData.isEmpty() ? null : punishmentData - ); - - result.add(punishment); - } - - return result; - } - - private List parseModifications(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (!(item instanceof Map)) { - continue; - } - Map m = (Map) item; - - String type = validator.sanitizeString((String) m.get("type"), 100); - if (type == null) { - continue; - } - try { - PunishmentModificationType.valueOf(type); - } catch (IllegalArgumentException ignored) { - continue; - } - - Date date = validator.parseDate(m.get("date")); - if (date == null) { - continue; - } - - String sourceId = validator.sanitizeString((String) m.get("id"), 100); - String id = (sourceId != null && !sourceId.isBlank()) ? sourceId : IdGenerator.generateShortId(); - - String issuerName = validator.sanitizeString((String) m.get("issuerName"), 100); - String issuerId = validator.sanitizeString((String) m.get("issuerId"), 100); - - String reason = validator.sanitizeString((String) m.get("reason"), 1000); - if (reason == null) { - reason = ""; - } - - Long effectiveDuration = (m.get("effectiveDuration") instanceof Number n) ? n.longValue() : null; - String appealTicketId = validator.sanitizeString((String) m.get("appealTicketId"), 100); - - Map modData = null; - if (m.get("data") instanceof Map) { - modData = new HashMap<>(); - for (Map.Entry entry : ((Map) m.get("data")).entrySet()) { - if (entry.getKey() instanceof String) { - modData.put((String) entry.getKey(), entry.getValue()); - } - } - } - - result.add(new PunishmentModification( - id, type, date, issuerName, issuerId, reason, effectiveDuration, appealTicketId, modData)); - } - - return result; - } - - private List parseEvidence(Object data) { - List result = new ArrayList<>(); - if (!(data instanceof List)) { - return result; - } - - for (Object item : (List) data) { - if (item instanceof String) { - String text = validator.sanitizeString((String) item, 5000); - if (text == null || text.isBlank()) { - continue; - } - result.add(new PunishmentEvidence(text, null, "text", null, null, new Date(), null, null, null)); - } else if (item instanceof Map) { - Map m = (Map) item; - String text = validator.sanitizeString((String) m.get("text"), 5000); - String sanitizedUrl = validator.sanitizeString((String) m.get("url"), 2000); - String url = SafeUrls.isSafe(sanitizedUrl) ? sanitizedUrl : null; - String type = validator.sanitizeString((String) m.get("type"), 100); - if (type == null || type.isBlank()) { - type = "link"; - } - String uploadedBy = validator.sanitizeString((String) m.get("uploadedBy"), 100); - String uploadedById = validator.sanitizeString((String) m.get("uploadedById"), 100); - Date uploadedAt = validator.parseDate(m.get("uploadedAt")); - if (uploadedAt == null) { - uploadedAt = new Date(); - } - String fileName = validator.sanitizeString((String) m.get("fileName"), 500); - String fileType = validator.sanitizeString((String) m.get("fileType"), 100); - Long fileSize = (m.get("fileSize") instanceof Number n) ? n.longValue() : null; - - result.add(new PunishmentEvidence( - text, url, type, uploadedBy, uploadedById, uploadedAt, fileName, fileType, fileSize)); - } - } - - return result; - } - - private static final class ProgressCounters { - private int processed; - private int skipped; - private int lastAnnounced; - private Integer total; - - private boolean dueForAnnounce(int interval) { - if (processed - lastAnnounced >= interval) { - lastAnnounced = processed; - return true; - } - return false; - } - - private void addProcessed(int value) { - processed += value; - } - - private void addSkipped(int value) { - skipped += value; - } - - private void skipOne() { - skipped++; - } - - private int processed() { - return processed; - } - - private int skipped() { - return skipped; - } - - private Integer total() { - return total; - } - - private void total(Integer value) { - total = value; - } - } - - private static final class MigrationDataException extends RuntimeException { - private MigrationDataException(String message) { - super(message); - } - } -} +package gg.modl.backend.migration.service; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.migration.validation.MigrationValidator; +import gg.modl.backend.player.PlayerDocumentIdGenerator; +import gg.modl.backend.player.data.IPEntry; +import gg.modl.backend.player.data.NoteEntry; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.UsernameEntry; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentEvidence; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.data.punishment.PunishmentStatus; +import gg.modl.backend.server.data.Server; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.validation.SafeUrls; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MigrationProcessor { + private final PlayerMongoRepository playerRepository; + private final MigrationService migrationService; + private final MigrationValidator validator; + private final ObjectMapper objectMapper; + + private static final int BATCH_SIZE = 500; + private static final int PROGRESS_UPDATE_INTERVAL = 1000; + private static final int MAX_JSON_NESTING_DEPTH = 100; + private static final int MAX_JSON_STRING_LENGTH = 1_000_000; + private static final int MAX_FAILURE_MESSAGE_LENGTH = 900; + private static final String PLAYERS_FIELD = "players"; + private static final String METADATA_FIELD = "metadata"; + private static final String PLAYER_COUNT_FIELD = "playerCount"; + + @Async("migrationTaskExecutor") + public void processFileAsync(Server server, Path filePath) { + try { + processFile(server, filePath); + } catch (Exception e) { + log.error("Async migration processing failed", e); + } + } + + public void processFile(Server server, Path filePath) { + ProgressCounters counters = new ProgressCounters(); + + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "processing_data", + "Reading and validating migration file...", + 0, 0, null + )); + + ObjectMapper constrainedMapper = objectMapper.copy(); + constrainedMapper.getFactory().setStreamReadConstraints(StreamReadConstraints.builder() + .maxNestingDepth(MAX_JSON_NESTING_DEPTH) + .maxStringLength(MAX_JSON_STRING_LENGTH) + .build()); + + if (streamMigrationFile(server, filePath, constrainedMapper, counters)) { + return; + } + + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "completed", + "Migration completed successfully", + counters.processed(), counters.skipped(), counters.total() + )); + } catch (Exception e) { + log.error("Failed to persist terminal migration state (completed)", e); + } + + } catch (MigrationDataException e) { + failMigration(server, e.getMessage(), counters); + } catch (Exception e) { + log.error("Error processing migration file", e); + failMigration(server, boundFailureMessage("Migration failed: ", e.getMessage()), counters); + } finally { + try { + Files.deleteIfExists(filePath); + } catch (IOException e) { + log.warn("Failed to delete migration file: {}", filePath, e); + } + } + } + + private void failMigration(Server server, String message, ProgressCounters counters) { + try { + migrationService.updateProgress(server, new UpdateProgressRequest( + "failed", message, counters.processed(), counters.skipped(), null + )); + } catch (Exception e) { + log.error("Failed to persist terminal migration state (failed)", e); + } + } + + private boolean streamMigrationFile(Server server, Path filePath, ObjectMapper mapper, + ProgressCounters counters) throws IOException { + JsonFactory factory = mapper.getFactory(); + try (JsonParser parser = factory.createParser(filePath.toFile())) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw new MigrationDataException("Migration data must be a JSON object"); + } + + boolean playersStreamed = false; + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.currentName(); + parser.nextToken(); + + if (PLAYERS_FIELD.equals(field)) { + MigrationValidator.ValidationResult header = validator.validateHeader( + true, parser.currentToken() == JsonToken.START_ARRAY, counters.total()); + if (!header.valid()) { + throw new MigrationDataException(header.error()); + } + announceProcessingProgress(server, counters); + if (streamPlayers(server, parser, mapper, counters)) { + return true; + } + playersStreamed = true; + } else if (METADATA_FIELD.equals(field)) { + counters.total(readDeclaredPlayerCount(parser)); + } else { + parser.skipChildren(); + } + } + + if (!playersStreamed) { + throw new MigrationDataException( + validator.validateHeader(false, false, counters.total()).error()); + } + + return false; + } + } + + private boolean streamPlayers(Server server, JsonParser parser, ObjectMapper mapper, + ProgressCounters counters) throws IOException { + long seen = 0; + List> batch = new ArrayList<>(BATCH_SIZE); + + while (parser.nextToken() != JsonToken.END_ARRAY) { + seen++; + if (seen > MigrationValidator.MAX_PLAYER_RECORDS) { + throw new MigrationDataException("Players array exceeds maximum length of 1,000,000"); + } + + if (parser.currentToken() == JsonToken.START_OBJECT) { + batch.add(mapper.readValue(parser, Map.class)); + } else { + parser.skipChildren(); + counters.skipOne(); + } + + if (batch.size() >= BATCH_SIZE && drainBatch(server, batch, counters)) { + return true; + } + } + + if (seen == 0) { + throw new MigrationDataException("Players array cannot be empty"); + } + + return !batch.isEmpty() && drainBatch(server, batch, counters); + } + + private boolean drainBatch(Server server, List> batch, ProgressCounters counters) { + int[] results = processBatch(server, batch); + counters.addProcessed(results[0]); + counters.addSkipped(results[1]); + batch.clear(); + + if (counters.dueForAnnounce(PROGRESS_UPDATE_INTERVAL)) { + announceProcessingProgress(server, counters); + } + + if (!migrationService.isActiveMigrationPresent(server)) { + log.info("Migration cancelled or no longer active; stopping processing after {} records", + counters.processed()); + return true; + } + return false; + } + + private void announceProcessingProgress(Server server, ProgressCounters counters) { + migrationService.updateProgress(server, new UpdateProgressRequest( + "processing_data", + processingMessage(counters.processed(), counters.total()), + counters.processed(), counters.skipped(), counters.total() + )); + } + + private Integer readDeclaredPlayerCount(JsonParser parser) throws IOException { + if (parser.currentToken() != JsonToken.START_OBJECT) { + parser.skipChildren(); + return null; + } + Integer playerCount = null; + while (parser.nextToken() != JsonToken.END_OBJECT) { + String field = parser.currentName(); + parser.nextToken(); + if (PLAYER_COUNT_FIELD.equals(field) && parser.currentToken().isNumeric()) { + long value = parser.getValueAsLong(-1L); + playerCount = (int) Math.max(Integer.MIN_VALUE, Math.min(Integer.MAX_VALUE, value)); + } else { + parser.skipChildren(); + } + } + return playerCount; + } + + private static String processingMessage(int processed, Integer total) { + if (total == null) { + return "Processing player records... (" + processed + ")"; + } + return "Processing player records... (" + processed + "/" + total + ")"; + } + + private static String boundFailureMessage(String prefix, String detail) { + String message = prefix + (detail == null ? "unknown error" : detail); + if (message.length() > MAX_FAILURE_MESSAGE_LENGTH) { + return message.substring(0, MAX_FAILURE_MESSAGE_LENGTH - 1) + "…"; + } + return message; + } + + private int[] processBatch(Server server, List> batch) { + int processed = 0; + int skipped = 0; + + List uuids = new ArrayList<>(); + Map> playerDataMap = new HashMap<>(); + + for (Map playerMap : batch) { + Object uuidObj = playerMap.get("minecraftUuid"); + if (uuidObj == null || !(uuidObj instanceof String)) { + skipped++; + continue; + } + + String uuid = validator.normalizeUuid((String) uuidObj); + if (!validator.isValidUuid(uuid)) { + skipped++; + continue; + } + + uuids.add(uuid); + playerDataMap.put(uuid, playerMap); + } + + if (uuids.isEmpty()) { + return new int[]{0, skipped}; + } + + List existingPlayers = playerRepository.findByMinecraftUuids(server, + uuids.stream().map(UUID::fromString).toList()); + Map existingMap = new HashMap<>(); + for (Player p : existingPlayers) { + existingMap.put(p.getMinecraftUuid().toString(), p); + } + + List toInsert = new ArrayList<>(); + Map mergeUpdates = new HashMap<>(); + + for (String uuid : uuids) { + try { + Map playerMap = playerDataMap.get(uuid); + Player existing = existingMap.get(uuid); + + if (existing != null) { + Update update = buildMergeUpdate(existing, playerMap); + if (update != null) { + mergeUpdates.put(UUID.fromString(uuid), update); + } + } else { + Player newPlayer = buildNewPlayer(uuid, playerMap); + if (newPlayer != null) { + toInsert.add(newPlayer); + } + } + processed++; + } catch (Exception e) { + log.warn("Error processing player {}", uuid, e); + skipped++; + } + } + + if (!toInsert.isEmpty()) { + playerRepository.insertAll(server, toInsert); + } + + if (!mergeUpdates.isEmpty()) { + playerRepository.bulkMergeByUuid(server, mergeUpdates); + } + + return new int[]{processed, skipped}; + } + + private Player buildNewPlayer(String uuid, Map data) { + try { + Object ipObj = data.get("ipAddresses") != null ? data.get("ipAddresses") : data.get("ipList"); + Player player = Player.builder() + .id(PlayerDocumentIdGenerator.generate()) + .minecraftUuid(UUID.fromString(uuid)) + .usernames(parseUsernames(data.get("usernames"))) + .notes(parseNotes(data.get("notes"))) + .ipAddresses(parseIpAddresses(ipObj)) + .punishments(parsePunishments(data.get("punishments"))) + .data(parseData(data.get("data"))) + .build(); + + return player; + } catch (Exception e) { + log.warn("Error building new player for UUID {}", uuid, e); + return null; + } + } + + private List parseIpAddresses(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List)) { + return result; + } + + for (Object item : (List) data) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String ipAddress = (String) map.get("ipAddress"); + if (!validator.isValidIpAddress(ipAddress)) { + continue; + } + + Date firstLogin = validator.parseDate(map.get("firstLogin")); + if (firstLogin == null) { + firstLogin = new Date(); + } + + List logins = new ArrayList<>(); + Object loginsObj = map.get("logins"); + if (loginsObj instanceof List) { + for (Object loginObj : (List) loginsObj) { + Date login = validator.parseDate(loginObj); + if (login != null) { + logins.add(login); + } + } + } + + result.add(IPEntry.builder() + .ipAddress(ipAddress) + .country(validator.sanitizeString((String) map.get("country"), 100)) + .region(validator.sanitizeString((String) map.get("region"), 100)) + .asn(validator.sanitizeString((String) map.get("asn"), 100)) + .proxy(Boolean.TRUE.equals(map.get("proxy"))) + .hosting(Boolean.TRUE.equals(map.get("hosting"))) + .firstLogin(firstLogin) + .logins(logins) + .build()); + } + + return result; + } + + @SuppressWarnings("unchecked") + private Map parseData(Object data) { + if (data instanceof Map) { + Map result = new HashMap<>(); + for (Map.Entry entry : ((Map) data).entrySet()) { + if (entry.getKey() instanceof String) { + result.put((String) entry.getKey(), entry.getValue()); + } + } + return result; + } + return new HashMap<>(); + } + + private Update buildMergeUpdate(Player existing, Map newData) { + Update update = new Update(); + boolean hasChanges = false; + + List newUsernames = parseUsernames(newData.get("usernames")); + if (!newUsernames.isEmpty()) { + Set existingNames = new HashSet<>(); + if (existing.getUsernames() != null) { + for (UsernameEntry u : existing.getUsernames()) { + existingNames.add(u.username()); + } + } + List toAddUsernames = new ArrayList<>(); + for (UsernameEntry u : newUsernames) { + if (existingNames.add(u.username())) { + toAddUsernames.add(u); + } + } + if (!toAddUsernames.isEmpty()) { + update.push("usernames").each(toAddUsernames.toArray()); + hasChanges = true; + } + } + + List newNotes = parseNotes(newData.get("notes")); + if (!newNotes.isEmpty()) { + Set existingNoteIds = new HashSet<>(); + if (existing.getNotes() != null) { + for (NoteEntry note : existing.getNotes()) { + existingNoteIds.add(note.getId()); + } + } + List toAddNotes = new ArrayList<>(); + for (NoteEntry note : newNotes) { + if (existingNoteIds.add(note.getId())) { + toAddNotes.add(note); + } + } + if (!toAddNotes.isEmpty()) { + update.push("notes").each(toAddNotes.toArray()); + hasChanges = true; + } + } + + List newPunishments = parsePunishments(newData.get("punishments")); + if (!newPunishments.isEmpty()) { + Set existingIds = new HashSet<>(); + if (existing.getPunishments() != null) { + for (Punishment p : existing.getPunishments()) { + existingIds.add(p.getId()); + } + } + List toAddPunishments = new ArrayList<>(); + for (Punishment p : newPunishments) { + if (existingIds.add(p.getId())) { + toAddPunishments.add(p); + } + } + if (!toAddPunishments.isEmpty()) { + update.push("punishments").each(toAddPunishments.toArray()); + hasChanges = true; + } + } + + Object ipObj = newData.get("ipAddresses") != null ? newData.get("ipAddresses") : newData.get("ipList"); + List newIps = parseIpAddresses(ipObj); + if (!newIps.isEmpty()) { + Set existingIps = new HashSet<>(); + if (existing.getIpAddresses() != null) { + for (IPEntry ip : existing.getIpAddresses()) { + if (ip.getIpAddress() != null) { + existingIps.add(ip.getIpAddress()); + } + } + } + List ipsToAdd = new ArrayList<>(); + for (IPEntry ip : newIps) { + if (ip.getIpAddress() != null && existingIps.add(ip.getIpAddress())) { + ipsToAdd.add(ip); + } + } + if (!ipsToAdd.isEmpty()) { + update.push("ipAddresses").each(ipsToAdd.toArray()); + hasChanges = true; + } + } + + return hasChanges ? update : null; + } + + private List parseUsernames(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List)) { + return result; + } + + for (Object item : (List) data) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String username = validator.sanitizeString((String) map.get("username"), 100); + Date date = validator.parseDate(map.get("date")); + + if (username != null && !username.isBlank() && date != null) { + result.add(new UsernameEntry(username, date)); + } + } + + return result; + } + + private List parseNotes(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List)) { + return result; + } + + for (Object item : (List) data) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + String text = validator.sanitizeString((String) map.get("text"), 5000); + Date date = validator.parseDate(map.get("date")); + String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); + + if (text != null && date != null && issuerName != null) { + String sourceId = validator.sanitizeString((String) map.get("id"), 100); + String noteId = (sourceId != null && !sourceId.isBlank()) + ? sourceId + : UUID.nameUUIDFromBytes( + (text + "|" + date.getTime() + "|" + issuerName).getBytes(StandardCharsets.UTF_8)) + .toString(); + result.add(new NoteEntry( + noteId, + text, + date, + issuerName, + null + )); + } + } + + return result; + } + + private List parsePunishments(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List)) { + return result; + } + + for (Object item : (List) data) { + if (!(item instanceof Map)) { + continue; + } + Map map = (Map) item; + + Object idObj = map.get("id") != null ? map.get("id") : map.get("_id"); + String id = idObj instanceof String s ? s : null; + + Date issued = validator.parseDate(map.get("issued")); + if (issued == null) { + continue; + } + + String issuerName = validator.sanitizeString((String) map.get("issuerName"), 100); + if (issuerName == null) { + issuerName = "Unknown"; + } + + Object typeOrdinalObj = map.get("typeOrdinal"); + int typeOrdinal = 0; + if (typeOrdinalObj instanceof Number) { + typeOrdinal = ((Number) typeOrdinalObj).intValue(); + } else if (typeOrdinalObj instanceof String typeOrdinalString) { + try { + typeOrdinal = Integer.parseInt(typeOrdinalString); + } catch (NumberFormatException ignored) { + typeOrdinal = 0; + } + } + + List notes = new ArrayList<>(); + Object notesObj = map.get("notes"); + if (notesObj instanceof List) { + for (Object noteObj : (List) notesObj) { + if (noteObj instanceof Map) { + Map noteMap = (Map) noteObj; + String text = validator.sanitizeString((String) noteMap.get("text"), 5000); + Date date = validator.parseDate(noteMap.get("date")); + String noteIssuer = validator.sanitizeString((String) noteMap.get("issuerName"), 100); + + if (text != null && date != null) { + notes.add(new PunishmentNote(IdGenerator.generateShortId(), text, date, noteIssuer != null ? noteIssuer : "Unknown", null)); + } + } + } + } + + List evidence = parseEvidence(map.get("evidence")); + + List modifications = parseModifications(map.get("modifications")); + + List attachedTicketIds = new ArrayList<>(); + Object ticketIdsObj = map.get("attachedTicketIds"); + if (ticketIdsObj instanceof List) { + for (Object ticketId : (List) ticketIdsObj) { + if (ticketId instanceof String) { + attachedTicketIds.add((String) ticketId); + } + } + } + + Map punishmentData = new HashMap<>(); + Object dataObj = map.get("data"); + if (dataObj instanceof Map) { + for (Map.Entry entry : ((Map) dataObj).entrySet()) { + if (entry.getKey() instanceof String) { + punishmentData.put((String) entry.getKey(), entry.getValue()); + } + } + } + + String reason = validator.sanitizeString((String) map.get("reason"), 1000); + if (reason != null && !reason.isBlank()) { + punishmentData.put("reason", reason); + } + + Object durationObj = map.get("duration"); + if (durationObj instanceof Number) { + punishmentData.put("duration", ((Number) durationObj).longValue()); + } + + Date started = validator.parseDate(map.get("started")); + + if (id == null || id.isBlank()) { + id = "import-" + UUID.nameUUIDFromBytes( + (typeOrdinal + "|" + issued.getTime() + "|" + issuerName + "|" + (reason != null ? reason : "")) + .getBytes(StandardCharsets.UTF_8)).toString(); + } + + Object activeObj = punishmentData.get("active"); + Object pardonedBy = punishmentData.get("pardonedBy"); + boolean sourceInactive = Boolean.FALSE.equals(activeObj) + || (activeObj instanceof String activeStr && "false".equalsIgnoreCase(activeStr)) + || pardonedBy != null; + boolean alreadyPardoned = modifications.stream() + .anyMatch(m -> PunishmentModificationType.isPardon(m.type())); + if (sourceInactive && !alreadyPardoned) { + Date pardonDate = validator.parseDate(punishmentData.get("pardonedDate")); + if (pardonDate == null) { + pardonDate = validator.parseDate(punishmentData.get("removedAt")); + } + if (pardonDate == null) { + pardonDate = issued; + } + String pardonIssuer = pardonedBy instanceof String pardonStr ? pardonStr : "System"; + modifications.add(new PunishmentModification( + IdGenerator.generateShortId(), + PunishmentModificationType.SYSTEM_PARDON.name(), + pardonDate, + pardonIssuer, + null, + "Imported as already removed/inactive", + null, + null, + null + )); + punishmentData.put("status", PunishmentStatus.PARDONED); + } + + Punishment punishment = new Punishment( + id, + typeOrdinal, + issuerName, + null, + issued, + started, + modifications, + notes, + evidence, + attachedTicketIds, + punishmentData.isEmpty() ? null : punishmentData + ); + + result.add(punishment); + } + + return result; + } + + private List parseModifications(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List)) { + return result; + } + + for (Object item : (List) data) { + if (!(item instanceof Map)) { + continue; + } + Map m = (Map) item; + + String type = validator.sanitizeString((String) m.get("type"), 100); + if (type == null) { + continue; + } + try { + PunishmentModificationType.valueOf(type); + } catch (IllegalArgumentException ignored) { + continue; + } + + Date date = validator.parseDate(m.get("date")); + if (date == null) { + continue; + } + + String sourceId = validator.sanitizeString((String) m.get("id"), 100); + String id = (sourceId != null && !sourceId.isBlank()) ? sourceId : IdGenerator.generateShortId(); + + String issuerName = validator.sanitizeString((String) m.get("issuerName"), 100); + String issuerId = validator.sanitizeString((String) m.get("issuerId"), 100); + + String reason = validator.sanitizeString((String) m.get("reason"), 1000); + if (reason == null) { + reason = ""; + } + + Long effectiveDuration = (m.get("effectiveDuration") instanceof Number n) ? n.longValue() : null; + String appealTicketId = validator.sanitizeString((String) m.get("appealTicketId"), 100); + + Map modData = null; + if (m.get("data") instanceof Map) { + modData = new HashMap<>(); + for (Map.Entry entry : ((Map) m.get("data")).entrySet()) { + if (entry.getKey() instanceof String) { + modData.put((String) entry.getKey(), entry.getValue()); + } + } + } + + result.add(new PunishmentModification( + id, type, date, issuerName, issuerId, reason, effectiveDuration, appealTicketId, modData)); + } + + return result; + } + + private List parseEvidence(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List)) { + return result; + } + + for (Object item : (List) data) { + if (item instanceof String) { + String text = validator.sanitizeString((String) item, 5000); + if (text == null || text.isBlank()) { + continue; + } + result.add(new PunishmentEvidence(text, null, "text", null, null, new Date(), null, null, null)); + } else if (item instanceof Map) { + Map m = (Map) item; + String text = validator.sanitizeString((String) m.get("text"), 5000); + String sanitizedUrl = validator.sanitizeString((String) m.get("url"), 2000); + String url = SafeUrls.isSafe(sanitizedUrl) ? sanitizedUrl : null; + String type = validator.sanitizeString((String) m.get("type"), 100); + if (type == null || type.isBlank()) { + type = "link"; + } + String uploadedBy = validator.sanitizeString((String) m.get("uploadedBy"), 100); + String uploadedById = validator.sanitizeString((String) m.get("uploadedById"), 100); + Date uploadedAt = validator.parseDate(m.get("uploadedAt")); + if (uploadedAt == null) { + uploadedAt = new Date(); + } + String fileName = validator.sanitizeString((String) m.get("fileName"), 500); + String fileType = validator.sanitizeString((String) m.get("fileType"), 100); + Long fileSize = (m.get("fileSize") instanceof Number n) ? n.longValue() : null; + + result.add(new PunishmentEvidence( + text, url, type, uploadedBy, uploadedById, uploadedAt, fileName, fileType, fileSize)); + } + } + + return result; + } + + private static final class ProgressCounters { + private int processed; + private int skipped; + private int lastAnnounced; + private Integer total; + + private boolean dueForAnnounce(int interval) { + if (processed - lastAnnounced >= interval) { + lastAnnounced = processed; + return true; + } + return false; + } + + private void addProcessed(int value) { + processed += value; + } + + private void addSkipped(int value) { + skipped += value; + } + + private void skipOne() { + skipped++; + } + + private int processed() { + return processed; + } + + private int skipped() { + return skipped; + } + + private Integer total() { + return total; + } + + private void total(Integer value) { + total = value; + } + } + + private static final class MigrationDataException extends RuntimeException { + private MigrationDataException(String message) { + super(message); + } + } +} diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationService.java b/src/main/java/gg/modl/backend/migration/service/MigrationService.java index 2203083..8a762c1 100644 --- a/src/main/java/gg/modl/backend/migration/service/MigrationService.java +++ b/src/main/java/gg/modl/backend/migration/service/MigrationService.java @@ -1,236 +1,236 @@ -package gg.modl.backend.migration.service; - -import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.migration.data.MigrationStatus; -import gg.modl.backend.migration.dto.UpdateProgressRequest; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.SyncMigrationTask; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import gg.modl.backend.migration.config.MigrationConfiguration; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.Nullable; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -@Service -@RequiredArgsConstructor -@Slf4j -public class MigrationService { - private final MigrationMongoRepository migrationRepository; - private final MigrationConfiguration migrationConfiguration; - private final RealtimeEventPublisher publisher; - private final ServerLimitPolicy serverLimitPolicy; - - public record CooldownState(boolean onCooldown, @Nullable Long remainingTime) {} - - private static final List VALID_TYPES = List.of("litebans"); - private static final List VALID_STATUSES = List.of( - "idle", "building_json", "uploading_json", "processing_data", "completed", "failed" - ); - private static final long COOLDOWN_MS = 60 * 60 * 1000; - private static final long STALE_MIGRATION_MS = 30 * 60 * 1000; - private static final int MAX_MESSAGE_LENGTH = 1000; - - public Optional getLatestMigration(Server server) { - Date now = new Date(); - Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); - migrationRepository.failStaleMigrations(server, staleBefore, now, - "Migration timed out and was automatically cancelled."); - return migrationRepository.findLatest(server); - } - - public boolean isActiveMigrationPresent(Server server) { - return migrationRepository.existsActiveMigration(server); - } - - public CooldownState checkCooldown(Server server) { - MigrationStatus lastMigration = migrationRepository.findLatestCompletedOrFailed(server).orElse(null); - - if (lastMigration == null || lastMigration.getCompletedAt() == null) { - return new CooldownState(false, null); - } - - long timeSinceCompletion = System.currentTimeMillis() - lastMigration.getCompletedAt().getTime(); - - if (timeSinceCompletion < COOLDOWN_MS) { - return new CooldownState(true, COOLDOWN_MS - timeSinceCompletion); - } - - return new CooldownState(false, null); - } - - public Map startMigration(Server server, String migrationType) { - if (!VALID_TYPES.contains(migrationType.toLowerCase())) { - return Map.of("success", false, "error", "Invalid migration type"); - } - - Date now = new Date(); - Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); - migrationRepository.failStaleMigrations(server, staleBefore, now, - "Migration timed out and was automatically cancelled."); - - if (migrationRepository.existsActiveMigration(server, staleBefore)) { - return Map.of("success", false, "error", "A migration is already in progress"); - } - - CooldownState cooldown = checkCooldown(server); - if (cooldown.onCooldown()) { - return Map.of("success", false, "error", "Migration on cooldown. Please wait before starting another migration."); - } - - String taskId = UUID.randomUUID().toString(); - String type = migrationType.toLowerCase(); - - MigrationStatus status = MigrationStatus.builder() - .taskId(taskId) - .type(type) - .status("building_json") - .progress(MigrationStatus.MigrationProgress.builder() - .message("Waiting for Minecraft server to build migration file...") - .recordsProcessed(0) - .recordsSkipped(0) - .build()) - .startedAt(now) - .build(); - - migrationRepository.saveEntity(server, status); - - publisher.pushMigrationTask(server, SyncMigrationTask.newBuilder() - .setTaskId(taskId) - .setType(type) - .build()); - - return Map.of( - "success", true, - "taskId", taskId, - "message", "Migration task initiated. Waiting for Minecraft server to process." - ); - } - - public Map cancelMigration(Server server) { - MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); - - if (activeMigration == null) { - return Map.of("success", false, "error", "No active migration to cancel"); - } - - boolean cooldownExempt = "building_json".equals(activeMigration.getStatus()); - - migrationRepository.cancelMigration(server, activeMigration.getId(), - "Cancelled by administrator", new Date(), "Migration cancelled by administrator", cooldownExempt); - - return Map.of("success", true, "message", "Migration cancelled successfully"); - } - - public Map validateFileSize(Server server, MultipartFile file) { - long fileSizeLimit = getFileSizeLimit(server); - if (file.getSize() <= fileSizeLimit) { - return null; - } - - double fileSizeMB = file.getSize() / (1024.0 * 1024.0); - double limitMB = fileSizeLimit / (1024.0 * 1024.0); - - updateProgress(server, new UpdateProgressRequest( - "failed", "Migration file exceeds size limit", 0, 0, null - )); - - return Map.of( - "error", "Migration file exceeds size limit", - "message", String.format("File size (%.2fMB) exceeds the limit of %.2fMB.", fileSizeMB, limitMB), - "fileSize", file.getSize(), - "limit", fileSizeLimit - ); - } - - public void updateProgress(Server server, UpdateProgressRequest request) { - if (request.status() == null || !VALID_STATUSES.contains(request.status())) { - throw new ValidationException("Invalid status value"); - } - - if (request.message() == null) { - throw new ValidationException("Message is required"); - } - - if (request.recordsProcessed() != null && request.recordsProcessed() < 0) { - throw new ValidationException("Invalid recordsProcessed value"); - } - - if (request.totalRecords() != null && request.totalRecords() < 0) { - throw new ValidationException("Invalid totalRecords value"); - } - - String message = clampMessage(request.message()); - - MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); - - if (activeMigration == null) { - throw new ResourceNotFoundException("No active migration found"); - } - - Date completedAt = ("completed".equals(request.status()) || "failed".equals(request.status())) - ? new Date() : null; - - migrationRepository.updateProgress(server, activeMigration.getId(), - request.status(), message, - request.recordsProcessed(), request.recordsSkipped(), - request.totalRecords(), completedAt); - } - - private static String clampMessage(String message) { - if (message == null || message.length() <= MAX_MESSAGE_LENGTH) { - return message; - } - return message.substring(0, MAX_MESSAGE_LENGTH - 1) + "…"; - } - - public long getFileSizeLimit(Server server) { - return serverLimitPolicy.resolve(server).getMigrationFileSizeLimit(); - } - - public Path saveUploadedFile(MultipartFile file) { - try { - Path uploadPath = Paths.get(migrationConfiguration.getUploadDir()).toAbsolutePath(); - Files.createDirectories(uploadPath); - String uniqueFilename = "migration-" + UUID.randomUUID() + ".json"; - Path filePath = uploadPath.resolve(uniqueFilename); - file.transferTo(filePath.toAbsolutePath()); - return filePath; - } catch (IOException e) { - throw new ExternalServiceException("Failed to save migration file", e); - } - } - - public void requireActiveMigrationForUpload(Server server) { - if (!migrationRepository.existsActiveMigration(server)) { - throw new ResourceNotFoundException("No active migration found for upload"); - } - } - - public void discardUpload(Server server, Path filePath, String reason) { - try { - Files.deleteIfExists(filePath); - } catch (IOException e) { - log.warn("Failed to delete orphaned migration upload {}", filePath, e); - } - try { - updateProgress(server, new UpdateProgressRequest("failed", reason, 0, 0, null)); - } catch (Exception e) { - log.warn("Failed to mark migration failed after discarding upload {}", filePath, e); - } - } -} +package gg.modl.backend.migration.service; + +import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.migration.data.MigrationStatus; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.SyncMigrationTask; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import gg.modl.backend.migration.config.MigrationConfiguration; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MigrationService { + private final MigrationMongoRepository migrationRepository; + private final MigrationConfiguration migrationConfiguration; + private final RealtimeEventPublisher publisher; + private final ServerLimitPolicy serverLimitPolicy; + + public record CooldownState(boolean onCooldown, @Nullable Long remainingTime) {} + + private static final List VALID_TYPES = List.of("litebans"); + private static final List VALID_STATUSES = List.of( + "idle", "building_json", "uploading_json", "processing_data", "completed", "failed" + ); + private static final long COOLDOWN_MS = 60 * 60 * 1000; + private static final long STALE_MIGRATION_MS = 30 * 60 * 1000; + private static final int MAX_MESSAGE_LENGTH = 1000; + + public Optional getLatestMigration(Server server) { + Date now = new Date(); + Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); + migrationRepository.failStaleMigrations(server, staleBefore, now, + "Migration timed out and was automatically cancelled."); + return migrationRepository.findLatest(server); + } + + public boolean isActiveMigrationPresent(Server server) { + return migrationRepository.existsActiveMigration(server); + } + + public CooldownState checkCooldown(Server server) { + MigrationStatus lastMigration = migrationRepository.findLatestCompletedOrFailed(server).orElse(null); + + if (lastMigration == null || lastMigration.getCompletedAt() == null) { + return new CooldownState(false, null); + } + + long timeSinceCompletion = System.currentTimeMillis() - lastMigration.getCompletedAt().getTime(); + + if (timeSinceCompletion < COOLDOWN_MS) { + return new CooldownState(true, COOLDOWN_MS - timeSinceCompletion); + } + + return new CooldownState(false, null); + } + + public Map startMigration(Server server, String migrationType) { + if (!VALID_TYPES.contains(migrationType.toLowerCase())) { + return Map.of("success", false, "error", "Invalid migration type"); + } + + Date now = new Date(); + Date staleBefore = new Date(now.getTime() - STALE_MIGRATION_MS); + migrationRepository.failStaleMigrations(server, staleBefore, now, + "Migration timed out and was automatically cancelled."); + + if (migrationRepository.existsActiveMigration(server, staleBefore)) { + return Map.of("success", false, "error", "A migration is already in progress"); + } + + CooldownState cooldown = checkCooldown(server); + if (cooldown.onCooldown()) { + return Map.of("success", false, "error", "Migration on cooldown. Please wait before starting another migration."); + } + + String taskId = UUID.randomUUID().toString(); + String type = migrationType.toLowerCase(); + + MigrationStatus status = MigrationStatus.builder() + .taskId(taskId) + .type(type) + .status("building_json") + .progress(MigrationStatus.MigrationProgress.builder() + .message("Waiting for Minecraft server to build migration file...") + .recordsProcessed(0) + .recordsSkipped(0) + .build()) + .startedAt(now) + .build(); + + migrationRepository.saveEntity(server, status); + + publisher.pushMigrationTask(server, SyncMigrationTask.newBuilder() + .setTaskId(taskId) + .setType(type) + .build()); + + return Map.of( + "success", true, + "taskId", taskId, + "message", "Migration task initiated. Waiting for Minecraft server to process." + ); + } + + public Map cancelMigration(Server server) { + MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); + + if (activeMigration == null) { + return Map.of("success", false, "error", "No active migration to cancel"); + } + + boolean cooldownExempt = "building_json".equals(activeMigration.getStatus()); + + migrationRepository.cancelMigration(server, activeMigration.getId(), + "Cancelled by administrator", new Date(), "Migration cancelled by administrator", cooldownExempt); + + return Map.of("success", true, "message", "Migration cancelled successfully"); + } + + public Map validateFileSize(Server server, MultipartFile file) { + long fileSizeLimit = getFileSizeLimit(server); + if (file.getSize() <= fileSizeLimit) { + return null; + } + + double fileSizeMB = file.getSize() / (1024.0 * 1024.0); + double limitMB = fileSizeLimit / (1024.0 * 1024.0); + + updateProgress(server, new UpdateProgressRequest( + "failed", "Migration file exceeds size limit", 0, 0, null + )); + + return Map.of( + "error", "Migration file exceeds size limit", + "message", String.format("File size (%.2fMB) exceeds the limit of %.2fMB.", fileSizeMB, limitMB), + "fileSize", file.getSize(), + "limit", fileSizeLimit + ); + } + + public void updateProgress(Server server, UpdateProgressRequest request) { + if (request.status() == null || !VALID_STATUSES.contains(request.status())) { + throw new ValidationException("Invalid status value"); + } + + if (request.message() == null) { + throw new ValidationException("Message is required"); + } + + if (request.recordsProcessed() != null && request.recordsProcessed() < 0) { + throw new ValidationException("Invalid recordsProcessed value"); + } + + if (request.totalRecords() != null && request.totalRecords() < 0) { + throw new ValidationException("Invalid totalRecords value"); + } + + String message = clampMessage(request.message()); + + MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); + + if (activeMigration == null) { + throw new ResourceNotFoundException("No active migration found"); + } + + Date completedAt = ("completed".equals(request.status()) || "failed".equals(request.status())) + ? new Date() : null; + + migrationRepository.updateProgress(server, activeMigration.getId(), + request.status(), message, + request.recordsProcessed(), request.recordsSkipped(), + request.totalRecords(), completedAt); + } + + private static String clampMessage(String message) { + if (message == null || message.length() <= MAX_MESSAGE_LENGTH) { + return message; + } + return message.substring(0, MAX_MESSAGE_LENGTH - 1) + "…"; + } + + public long getFileSizeLimit(Server server) { + return serverLimitPolicy.resolve(server).getMigrationFileSizeLimit(); + } + + public Path saveUploadedFile(MultipartFile file) { + try { + Path uploadPath = Paths.get(migrationConfiguration.getUploadDir()).toAbsolutePath(); + Files.createDirectories(uploadPath); + String uniqueFilename = "migration-" + UUID.randomUUID() + ".json"; + Path filePath = uploadPath.resolve(uniqueFilename); + file.transferTo(filePath.toAbsolutePath()); + return filePath; + } catch (IOException e) { + throw new ExternalServiceException("Failed to save migration file", e); + } + } + + public void requireActiveMigrationForUpload(Server server) { + if (!migrationRepository.existsActiveMigration(server)) { + throw new ResourceNotFoundException("No active migration found for upload"); + } + } + + public void discardUpload(Server server, Path filePath, String reason) { + try { + Files.deleteIfExists(filePath); + } catch (IOException e) { + log.warn("Failed to delete orphaned migration upload {}", filePath, e); + } + try { + updateProgress(server, new UpdateProgressRequest("failed", reason, 0, 0, null)); + } catch (Exception e) { + log.warn("Failed to mark migration failed after discarding upload {}", filePath, e); + } + } +} diff --git a/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java b/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java index eaba0a7..b194b55 100644 --- a/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java +++ b/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java @@ -1,150 +1,150 @@ -package gg.modl.backend.migration.validation; - -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import java.net.InetAddress; -import java.time.Instant; -import java.time.format.DateTimeParseException; -import java.util.Date; -import java.util.regex.Pattern; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class MigrationValidator { - public static final int MAX_PLAYER_RECORDS = 1_000_000; - - private static final Pattern UUID_PATTERN = Pattern.compile(RegExpConstants.UUID); - private static final Pattern UUID_NO_DASHES_PATTERN = Pattern.compile("^[0-9a-fA-F]{32}$"); - - public ValidationResult validateHeader(boolean playersPresent, boolean playersIsArray, - Integer declaredPlayerCount) { - if (!playersPresent) { - return ValidationResult.error("Missing 'players' field"); - } - if (!playersIsArray) { - return ValidationResult.error("'players' field must be an array"); - } - if (declaredPlayerCount != null) { - if (declaredPlayerCount <= 0) { - return ValidationResult.error("Players array cannot be empty"); - } - if (declaredPlayerCount > MAX_PLAYER_RECORDS) { - return ValidationResult.error("Players array exceeds maximum length of 1,000,000"); - } - } - return ValidationResult.success(declaredPlayerCount == null ? 0 : declaredPlayerCount); - } - - public boolean isValidUuid(String uuid) { - if (uuid == null || uuid.isBlank()) { - return false; - } - - if (UUID_PATTERN.matcher(uuid).matches()) { - return true; - } - - if (UUID_NO_DASHES_PATTERN.matcher(uuid).matches()) { - return true; - } - - return false; - } - - public String normalizeUuid(String uuid) { - if (uuid == null) { - return null; - } - - String cleaned = uuid.replace("-", "").toLowerCase(); - - if (cleaned.length() != 32) { - return uuid; - } - - return cleaned.substring(0, 8) + "-" + - cleaned.substring(8, 12) + "-" + - cleaned.substring(12, 16) + "-" + - cleaned.substring(16, 20) + "-" + - cleaned.substring(20); - } - - public String sanitizeString(String input, int maxLength) { - if (input == null) { - return null; - } - - String trimmed = input.trim(); - if (trimmed.length() > maxLength) { - trimmed = trimmed.substring(0, maxLength); - } - - return trimmed - .replace("\u0000", "") - .replace("\r", ""); - } - - public Date parseDate(Object dateObj) { - if (dateObj == null) { - return null; - } - - if (dateObj instanceof Date) { - return (Date) dateObj; - } - - if (dateObj instanceof Number) { - long timestamp = ((Number) dateObj).longValue(); - if (timestamp > 100_000_000_000L) { - return new Date(timestamp); - } else { - return new Date(timestamp * 1000); - } - } - - if (dateObj instanceof String) { - String dateStr = (String) dateObj; - try { - return Date.from(Instant.parse(dateStr)); - } catch (DateTimeParseException e) { - try { - long timestamp = Long.parseLong(dateStr); - if (timestamp > 100_000_000_000L) { - return new Date(timestamp); - } else { - return new Date(timestamp * 1000); - } - } catch (NumberFormatException ex) { - log.warn("Unable to parse date: {}", dateStr); - return null; - } - } - } - - return null; - } - - public boolean isValidIpAddress(String ip) { - if (ip == null || ip.isBlank()) { - return false; - } - - try { - InetAddress.getByName(ip); - return true; - } catch (Exception e) { - return false; - } - } - - public record ValidationResult(boolean valid, String error, int playerCount) { - public static ValidationResult success(int playerCount) { - return new ValidationResult(true, null, playerCount); - } - - public static ValidationResult error(String error) { - return new ValidationResult(false, error, 0); - } - } -} +package gg.modl.backend.migration.validation; + +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import java.net.InetAddress; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Date; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class MigrationValidator { + public static final int MAX_PLAYER_RECORDS = 1_000_000; + + private static final Pattern UUID_PATTERN = Pattern.compile(RegExpConstants.UUID); + private static final Pattern UUID_NO_DASHES_PATTERN = Pattern.compile("^[0-9a-fA-F]{32}$"); + + public ValidationResult validateHeader(boolean playersPresent, boolean playersIsArray, + Integer declaredPlayerCount) { + if (!playersPresent) { + return ValidationResult.error("Missing 'players' field"); + } + if (!playersIsArray) { + return ValidationResult.error("'players' field must be an array"); + } + if (declaredPlayerCount != null) { + if (declaredPlayerCount <= 0) { + return ValidationResult.error("Players array cannot be empty"); + } + if (declaredPlayerCount > MAX_PLAYER_RECORDS) { + return ValidationResult.error("Players array exceeds maximum length of 1,000,000"); + } + } + return ValidationResult.success(declaredPlayerCount == null ? 0 : declaredPlayerCount); + } + + public boolean isValidUuid(String uuid) { + if (uuid == null || uuid.isBlank()) { + return false; + } + + if (UUID_PATTERN.matcher(uuid).matches()) { + return true; + } + + if (UUID_NO_DASHES_PATTERN.matcher(uuid).matches()) { + return true; + } + + return false; + } + + public String normalizeUuid(String uuid) { + if (uuid == null) { + return null; + } + + String cleaned = uuid.replace("-", "").toLowerCase(); + + if (cleaned.length() != 32) { + return uuid; + } + + return cleaned.substring(0, 8) + "-" + + cleaned.substring(8, 12) + "-" + + cleaned.substring(12, 16) + "-" + + cleaned.substring(16, 20) + "-" + + cleaned.substring(20); + } + + public String sanitizeString(String input, int maxLength) { + if (input == null) { + return null; + } + + String trimmed = input.trim(); + if (trimmed.length() > maxLength) { + trimmed = trimmed.substring(0, maxLength); + } + + return trimmed + .replace("\u0000", "") + .replace("\r", ""); + } + + public Date parseDate(Object dateObj) { + if (dateObj == null) { + return null; + } + + if (dateObj instanceof Date) { + return (Date) dateObj; + } + + if (dateObj instanceof Number) { + long timestamp = ((Number) dateObj).longValue(); + if (timestamp > 100_000_000_000L) { + return new Date(timestamp); + } else { + return new Date(timestamp * 1000); + } + } + + if (dateObj instanceof String) { + String dateStr = (String) dateObj; + try { + return Date.from(Instant.parse(dateStr)); + } catch (DateTimeParseException e) { + try { + long timestamp = Long.parseLong(dateStr); + if (timestamp > 100_000_000_000L) { + return new Date(timestamp); + } else { + return new Date(timestamp * 1000); + } + } catch (NumberFormatException ex) { + log.warn("Unable to parse date: {}", dateStr); + return null; + } + } + } + + return null; + } + + public boolean isValidIpAddress(String ip) { + if (ip == null || ip.isBlank()) { + return false; + } + + try { + InetAddress.getByName(ip); + return true; + } catch (Exception e) { + return false; + } + } + + public record ValidationResult(boolean valid, String error, int playerCount) { + public static ValidationResult success(int playerCount) { + return new ValidationResult(true, null, playerCount); + } + + public static ValidationResult error(String error) { + return new ValidationResult(false, error, 0); + } + } +} diff --git a/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java b/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java index 6a880f0..bcccf28 100644 --- a/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java +++ b/src/main/java/gg/modl/backend/player/PlayerResponseMessage.java @@ -1,6 +1,6 @@ -package gg.modl.backend.player; - -public final class PlayerResponseMessage { - public static final String LOGIN_INVALID_SCHEMA = "Invalid login schema."; - public static final String LOGIN_SUCCESS = "Login saved."; -} +package gg.modl.backend.player; + +public final class PlayerResponseMessage { + public static final String LOGIN_INVALID_SCHEMA = "Invalid login schema."; + public static final String LOGIN_SUCCESS = "Login saved."; +} diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java index 2c8f2c6..667b8e9 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java @@ -1,291 +1,291 @@ -package gg.modl.backend.player.controller; - -import gg.modl.backend.player.dto.request.CreateNoteRequest; -import gg.modl.backend.player.service.MinecraftPlayerService; -import gg.modl.backend.player.service.PlayerLookupService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.backend.infrastructure.validation.ValidIpAddress; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) -@RequiredArgsConstructor -@Slf4j -@Validated -public class MinecraftPlayerController { - private final MinecraftPlayerService minecraftPlayerService; - private final PlayerLookupService playerLookupService; - - @PostMapping("/login") - public ResponseEntity> login( - @RequestBody @Valid LoginRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.login( - server, - UUID.fromString(request.minecraftUUID()), - request.username(), - request.ip(), - request.ipInfo(), - request.skinHash(), - request.serverName() - ); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/disconnect") - public ResponseEntity> disconnect( - @RequestBody @Valid DisconnectRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.disconnect(server, request.minecraftUuid(), request.sessionDurationMs())); - } - - @PostMapping("/update-server") - public ResponseEntity> updateServer( - @RequestBody @Valid UpdateServerRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.updateServer(server, request.minecraftUuid(), request.serverName())); - } - - @GetMapping("/online") - public ResponseEntity> getOnlinePlayers(HttpServletRequest httpRequest) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.getOnlinePlayers(server)); - } - - @GetMapping("/{uuid}") - public ResponseEntity> getPlayerByUuid( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(required = false) Integer punishmentLimit, - @RequestParam(required = false) Integer noteLimit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping - public ResponseEntity> getPlayerByQuery( - @RequestParam(required = false) @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @RequestParam(defaultValue = "true") boolean queryMojang, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/by-name") - public ResponseEntity> getPlayerByUsername( - @RequestParam String username, - @RequestParam(defaultValue = "true") boolean queryMojang, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUsername(server, username, queryMojang); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/lookup") - public ResponseEntity> lookupPlayer( - @RequestBody @Valid LookupRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupPlayer(server, request.query(), request.shouldQueryMojang()); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/lookup-profile") - public ResponseEntity> lookupProfile( - @RequestBody @Valid LookupRequest request, - @RequestParam(required = false) Integer punishmentLimit, - @RequestParam(required = false) Integer noteLimit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupProfile(server, request.query(), request.shouldQueryMojang(), - punishmentLimit, noteLimit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @PostMapping("/{uuid}/notes") - public ResponseEntity> createPlayerNote( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody @Valid CreateNoteRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote(server, uuid, request.text(), request.issuerName(), - request.issuerId()); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/linked-accounts") - public ResponseEntity> getLinkedAccounts( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) Integer page, - @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) Integer limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getLinkedAccounts(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/punishments") - public ResponseEntity> getPlayerPunishments( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/notes") - public ResponseEntity> getPlayerNotes( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerNotes(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); - } - - @GetMapping("/{uuid}/reports") - public ResponseEntity> getPlayerReports( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.getPlayerReports(server, uuid)); - } - - @PostMapping("/submit-ip-info") - public ResponseEntity> submitIpInfo( - @RequestBody @Valid SubmitIpInfoRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.submitIpInfo( - server, - request.minecraftUUID(), - request.ip(), - request.country(), - request.region(), - request.asn(), - request.proxy(), - request.hosting() - )); - } - - @PostMapping("/pardon") - public ResponseEntity> pardonPlayer( - @RequestBody @Valid PardonPlayerRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.pardonPlayer( - server, - request.playerName(), - request.punishmentType(), - request.issuerName(), - request.issuerId(), - request.reason() - ); - - if (Objects.equals(response.get("status"), 404)) { - return ResponseEntity.status(404).body(response); - } - return ResponseEntity.ok(response); - } - - public record LoginRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, - @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username, - @NotBlank @ValidIpAddress String ip, - Map ipInfo, - @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String skinHash, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, - String serverInstanceId - ) { - } - - public record SubmitIpInfoRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, - @NotBlank @ValidIpAddress String ip, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String country, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String region, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String asn, - boolean proxy, - boolean hosting - ) { - } - - public record DisconnectRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @Min(0) long sessionDurationMs, - String serverInstanceId - ) { - } - - public record UpdateServerRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @NotBlank @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, - String serverInstanceId - ) { - } - - public record LookupRequest( - @NotBlank @Size(max = RequestValidationLimits.ADMIN_SEARCH_QUERY_MAX_LENGTH) String query, - Boolean queryMojang - ) { - public boolean shouldQueryMojang() { - return queryMojang == null || queryMojang; - } - } - - public record PardonPlayerRequest( - @NotBlank @Size(max = RequestValidationLimits.LOG_USERNAME_MAX_LENGTH) String playerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String punishmentType, - @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason - ) { - } -} +package gg.modl.backend.player.controller; + +import gg.modl.backend.player.dto.request.CreateNoteRequest; +import gg.modl.backend.player.service.MinecraftPlayerService; +import gg.modl.backend.player.service.PlayerLookupService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.infrastructure.validation.ValidIpAddress; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) +@RequiredArgsConstructor +@Slf4j +@Validated +public class MinecraftPlayerController { + private final MinecraftPlayerService minecraftPlayerService; + private final PlayerLookupService playerLookupService; + + @PostMapping("/login") + public ResponseEntity> login( + @RequestBody @Valid LoginRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.login( + server, + UUID.fromString(request.minecraftUUID()), + request.username(), + request.ip(), + request.ipInfo(), + request.skinHash(), + request.serverName() + ); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @PostMapping("/disconnect") + public ResponseEntity> disconnect( + @RequestBody @Valid DisconnectRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(minecraftPlayerService.disconnect(server, request.minecraftUuid(), request.sessionDurationMs())); + } + + @PostMapping("/update-server") + public ResponseEntity> updateServer( + @RequestBody @Valid UpdateServerRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(minecraftPlayerService.updateServer(server, request.minecraftUuid(), request.serverName())); + } + + @GetMapping("/online") + public ResponseEntity> getOnlinePlayers(HttpServletRequest httpRequest) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(minecraftPlayerService.getOnlinePlayers(server)); + } + + @GetMapping("/{uuid}") + public ResponseEntity> getPlayerByUuid( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(required = false) Integer punishmentLimit, + @RequestParam(required = false) Integer noteLimit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @GetMapping + public ResponseEntity> getPlayerByQuery( + @RequestParam(required = false) @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @RequestParam(defaultValue = "true") boolean queryMojang, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @GetMapping("/by-name") + public ResponseEntity> getPlayerByUsername( + @RequestParam String username, + @RequestParam(defaultValue = "true") boolean queryMojang, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUsername(server, username, queryMojang); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @PostMapping("/lookup") + public ResponseEntity> lookupPlayer( + @RequestBody @Valid LookupRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupPlayer(server, request.query(), request.shouldQueryMojang()); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @PostMapping("/lookup-profile") + public ResponseEntity> lookupProfile( + @RequestBody @Valid LookupRequest request, + @RequestParam(required = false) Integer punishmentLimit, + @RequestParam(required = false) Integer noteLimit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupProfile(server, request.query(), request.shouldQueryMojang(), + punishmentLimit, noteLimit); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @PostMapping("/{uuid}/notes") + public ResponseEntity> createPlayerNote( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody @Valid CreateNoteRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote(server, uuid, request.text(), request.issuerName(), + request.issuerId()); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @GetMapping("/{uuid}/linked-accounts") + public ResponseEntity> getLinkedAccounts( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) Integer page, + @RequestParam(required = false) @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) Integer limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = playerLookupService.getLinkedAccounts(server, uuid, page, limit); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @GetMapping("/{uuid}/punishments") + public ResponseEntity> getPlayerPunishments( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @GetMapping("/{uuid}/notes") + public ResponseEntity> getPlayerNotes( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "7") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerNotes(server, uuid, page, limit); + return ResponseEntity.status(response.status()).body(response.body()); + } + + @GetMapping("/{uuid}/reports") + public ResponseEntity> getPlayerReports( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(minecraftPlayerService.getPlayerReports(server, uuid)); + } + + @PostMapping("/submit-ip-info") + public ResponseEntity> submitIpInfo( + @RequestBody @Valid SubmitIpInfoRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(minecraftPlayerService.submitIpInfo( + server, + request.minecraftUUID(), + request.ip(), + request.country(), + request.region(), + request.asn(), + request.proxy(), + request.hosting() + )); + } + + @PostMapping("/pardon") + public ResponseEntity> pardonPlayer( + @RequestBody @Valid PardonPlayerRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + Map response = minecraftPlayerService.pardonPlayer( + server, + request.playerName(), + request.punishmentType(), + request.issuerName(), + request.issuerId(), + request.reason() + ); + + if (Objects.equals(response.get("status"), 404)) { + return ResponseEntity.status(404).body(response); + } + return ResponseEntity.ok(response); + } + + public record LoginRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, + @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username, + @NotBlank @ValidIpAddress String ip, + Map ipInfo, + @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String skinHash, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, + String serverInstanceId + ) { + } + + public record SubmitIpInfoRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUUID, + @NotBlank @ValidIpAddress String ip, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String country, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String region, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String asn, + boolean proxy, + boolean hosting + ) { + } + + public record DisconnectRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @Min(0) long sessionDurationMs, + String serverInstanceId + ) { + } + + public record UpdateServerRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @NotBlank @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, + String serverInstanceId + ) { + } + + public record LookupRequest( + @NotBlank @Size(max = RequestValidationLimits.ADMIN_SEARCH_QUERY_MAX_LENGTH) String query, + Boolean queryMojang + ) { + public boolean shouldQueryMojang() { + return queryMojang == null || queryMojang; + } + } + + public record PardonPlayerRequest( + @NotBlank @Size(max = RequestValidationLimits.LOG_USERNAME_MAX_LENGTH) String playerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String punishmentType, + @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason + ) { + } +} diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java index e3c0bfa..197cde7 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java @@ -1,463 +1,463 @@ -package gg.modl.backend.player.controller; - -import com.fasterxml.jackson.annotation.JsonProperty; -import gg.modl.backend.player.dto.request.CreateUploadTokenRequest; -import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; +package gg.modl.backend.player.controller; + +import com.fasterxml.jackson.annotation.JsonProperty; +import gg.modl.backend.player.dto.request.CreateUploadTokenRequest; +import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; import gg.modl.backend.player.dto.response.PunishmentPreviewView; -import gg.modl.backend.player.service.PunishmentEvidenceService; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.player.service.PunishmentMutationService; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) -@RequiredArgsConstructor -@Validated -public class MinecraftPunishmentController { - private final PunishmentQueryService punishmentQueryService; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentEvidenceService punishmentEvidenceService; - private final PunishmentMutationService punishmentMutationService; - - @PostMapping("/create") - public ResponseEntity createPunishment( - @RequestBody @Valid MinecraftCreatePunishmentRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - punishmentLifecycleService.createMinecraftPunishment(server, request); - return ResponseEntity.ok().build(); - } - - @PostMapping("/dynamic") - public ResponseEntity> createPunishmentDynamic( - @RequestBody @Valid MinecraftCreatePunishmentRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - String punishmentId = punishmentLifecycleService.createMinecraftPunishment(server, request); - return ResponseEntity.ok(Map.of( - "status", 200, - "message", "Punishment created", - "punishmentId", punishmentId - )); - } - - @GetMapping("/{punishmentId}") - public ResponseEntity> getPunishmentById( - @PathVariable String punishmentId, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Map punishment = punishmentQueryService.getMinecraftPunishmentById(server, punishmentId).orElse(null); - if (punishment == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Punishment not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "punishment", punishment - )); - } - - @PostMapping("/{punishmentId}/upload-token") - public ResponseEntity> createUploadToken( - @PathVariable String punishmentId, - @RequestBody @Valid CreateUploadTokenRequest body, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - String token = punishmentQueryService.createEvidenceUploadToken(server, punishmentId, body.issuerName()).orElse(null); - if (token == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Punishment not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "token", token - )); - } - - @GetMapping("/recent") - public ResponseEntity> getRecentPunishments( - @RequestParam(defaultValue = "48") @Min(1) @Max(8760) int hours, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(Map.of( - "status", 200, - "punishments", punishmentQueryService.getRecentPunishments(server, hours) - )); - } - - @GetMapping("/preview") +import gg.modl.backend.player.service.PunishmentEvidenceService; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.player.service.PunishmentMutationService; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) +@RequiredArgsConstructor +@Validated +public class MinecraftPunishmentController { + private final PunishmentQueryService punishmentQueryService; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentEvidenceService punishmentEvidenceService; + private final PunishmentMutationService punishmentMutationService; + + @PostMapping("/create") + public ResponseEntity createPunishment( + @RequestBody @Valid MinecraftCreatePunishmentRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + punishmentLifecycleService.createMinecraftPunishment(server, request); + return ResponseEntity.ok().build(); + } + + @PostMapping("/dynamic") + public ResponseEntity> createPunishmentDynamic( + @RequestBody @Valid MinecraftCreatePunishmentRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + String punishmentId = punishmentLifecycleService.createMinecraftPunishment(server, request); + return ResponseEntity.ok(Map.of( + "status", 200, + "message", "Punishment created", + "punishmentId", punishmentId + )); + } + + @GetMapping("/{punishmentId}") + public ResponseEntity> getPunishmentById( + @PathVariable String punishmentId, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + Map punishment = punishmentQueryService.getMinecraftPunishmentById(server, punishmentId).orElse(null); + if (punishment == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Punishment not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "punishment", punishment + )); + } + + @PostMapping("/{punishmentId}/upload-token") + public ResponseEntity> createUploadToken( + @PathVariable String punishmentId, + @RequestBody @Valid CreateUploadTokenRequest body, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + String token = punishmentQueryService.createEvidenceUploadToken(server, punishmentId, body.issuerName()).orElse(null); + if (token == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Punishment not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "token", token + )); + } + + @GetMapping("/recent") + public ResponseEntity> getRecentPunishments( + @RequestParam(defaultValue = "48") @Min(1) @Max(8760) int hours, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(Map.of( + "status", 200, + "punishments", punishmentQueryService.getRecentPunishments(server, hours) + )); + } + + @GetMapping("/preview") public ResponseEntity previewPunishment( - @RequestParam String playerUuid, - @RequestParam int typeOrdinal, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(punishmentQueryService.previewPunishment(server, playerUuid, typeOrdinal)); - } - - @PostMapping("/acknowledge") - public ResponseEntity> acknowledgePunishment( - @RequestBody @Valid AcknowledgeRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( - server, - UUID.fromString(request.playerUuid()), - request.punishmentId() - ); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/pardon") - public ResponseEntity> pardonPunishment( - @PathVariable String punishmentId, - @RequestBody @Valid PardonRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( - server, - punishmentId, - request.issuerName(), - request.issuerId(), - request.reason() - ); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP -> ResponseEntity.ok(Map.of( - "status", 200, - "success", false, - "pardonedCount", 0, - "message", result.message() - )); - case SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "pardonedCount", 1, - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/note") - public ResponseEntity> addNote( - @PathVariable String punishmentId, - @RequestBody @Valid AddNoteRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentEvidenceService.addPunishmentNote( - server, - punishmentId, - request.note(), - request.issuerName(), - request.issuerId() - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - @PostMapping("/{punishmentId}/evidence") - public ResponseEntity> addEvidence( - @PathVariable String punishmentId, - @RequestBody @Valid AddEvidenceRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentEvidenceService.addEvidence( - server, - punishmentId, - request.evidenceUrl(), - request.issuerName(), - request.issuerId() - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - @PostMapping("/{punishmentId}/duration") - public ResponseEntity> changeDuration( - @PathVariable String punishmentId, - @RequestBody @Valid ChangeDurationRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.changeDuration( - server, - punishmentId, - request.newDuration(), - request.issuerName(), - request.issuerId() - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - @PostMapping("/{punishmentId}/toggle") - public ResponseEntity> toggleOption( - @PathVariable String punishmentId, - @RequestBody @Valid ToggleOptionRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.toggleOption( - server, - punishmentId, - request.option(), - request.enabled(), - request.issuerName(), - request.issuerId() - ); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "success", result.success(), - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/stat-wipe-acknowledge") - public ResponseEntity> acknowledgeStatWipe( - @PathVariable String punishmentId, - @RequestBody @Valid StatWipeAcknowledgeRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.acknowledgeStatWipe(server, punishmentId); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( - "status", 400, - "message", result.message() - )); - case NO_OP -> ResponseEntity.ok(Map.of( - "status", 200, - "message", result.message() - )); - case SUCCESS -> ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - }; - } - - @PostMapping("/{punishmentId}/tickets") - public ResponseEntity> modifyPunishmentTickets( - @PathVariable String punishmentId, - @RequestBody @Valid ModifyTicketsRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - PunishmentOperationResult result = punishmentMutationService.modifyPunishmentTickets( - server, - punishmentId, - new ModifyPunishmentTicketsRequest( - request.addTicketIds(), - request.removeTicketIds(), - request.modifyAssociatedTickets(), - request.issuerName(), - request.issuerId() - ) - ); - - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", result.message() - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", result.message() - )); - } - - public record MinecraftCreatePunishmentRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String targetUuid, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @JsonProperty("type_ordinal") @Min(0) int typeOrdinal, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_REASON_MAX_LENGTH) String reason, - @Min(0) Long duration, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_DATA_MAX_ENTRIES) Map data, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_NOTES_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String> notes, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> attachedTicketIds, - @Size(max = RequestValidationLimits.PLAYER_SEVERITY_MAX_LENGTH) String severity, - @Size(max = RequestValidationLimits.PLAYER_STATUS_MAX_LENGTH) String status - ) { - } - - public record AcknowledgeRequest( - @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String playerUuid, - @Size(max = RequestValidationLimits.TIMESTAMP_MAX_LENGTH) String executedAt, - boolean success, - @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String errorMessage - ) { - } - - public record PardonRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason, - @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String expectedType - ) { - } - - public record AddNoteRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @NotBlank @Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String note - ) { - } - - public record AddEvidenceRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @NotBlank @Size(max = RequestValidationLimits.EVIDENCE_URL_MAX_LENGTH) String evidenceUrl - ) { - } - - public record ChangeDurationRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Min(0) Long newDuration - ) { - } - - public record ToggleOptionRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @NotBlank @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_TYPE_MAX_LENGTH) String option, - boolean enabled - ) { - } - - public record StatWipeAcknowledgeRequest( - @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, - @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, - boolean success - ) { - } - - public record ModifyTicketsRequest( - @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, - @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> addTicketIds, - @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> removeTicketIds, - boolean modifyAssociatedTickets - ) { - } -} + @RequestParam String playerUuid, + @RequestParam int typeOrdinal, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + return ResponseEntity.ok(punishmentQueryService.previewPunishment(server, playerUuid, typeOrdinal)); + } + + @PostMapping("/acknowledge") + public ResponseEntity> acknowledgePunishment( + @RequestBody @Valid AcknowledgeRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( + server, + UUID.fromString(request.playerUuid()), + request.punishmentId() + ); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/pardon") + public ResponseEntity> pardonPunishment( + @PathVariable String punishmentId, + @RequestBody @Valid PardonRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentLifecycleService.pardonPunishment( + server, + punishmentId, + request.issuerName(), + request.issuerId(), + request.reason() + ); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP -> ResponseEntity.ok(Map.of( + "status", 200, + "success", false, + "pardonedCount", 0, + "message", result.message() + )); + case SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "pardonedCount", 1, + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/note") + public ResponseEntity> addNote( + @PathVariable String punishmentId, + @RequestBody @Valid AddNoteRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentEvidenceService.addPunishmentNote( + server, + punishmentId, + request.note(), + request.issuerName(), + request.issuerId() + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + @PostMapping("/{punishmentId}/evidence") + public ResponseEntity> addEvidence( + @PathVariable String punishmentId, + @RequestBody @Valid AddEvidenceRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentEvidenceService.addEvidence( + server, + punishmentId, + request.evidenceUrl(), + request.issuerName(), + request.issuerId() + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + @PostMapping("/{punishmentId}/duration") + public ResponseEntity> changeDuration( + @PathVariable String punishmentId, + @RequestBody @Valid ChangeDurationRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.changeDuration( + server, + punishmentId, + request.newDuration(), + request.issuerName(), + request.issuerId() + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + @PostMapping("/{punishmentId}/toggle") + public ResponseEntity> toggleOption( + @PathVariable String punishmentId, + @RequestBody @Valid ToggleOptionRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.toggleOption( + server, + punishmentId, + request.option(), + request.enabled(), + request.issuerName(), + request.issuerId() + ); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP, SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "success", result.success(), + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/stat-wipe-acknowledge") + public ResponseEntity> acknowledgeStatWipe( + @PathVariable String punishmentId, + @RequestBody @Valid StatWipeAcknowledgeRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.acknowledgeStatWipe(server, punishmentId); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + case INVALID_REQUEST -> ResponseEntity.badRequest().body(Map.of( + "status", 400, + "message", result.message() + )); + case NO_OP -> ResponseEntity.ok(Map.of( + "status", 200, + "message", result.message() + )); + case SUCCESS -> ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + }; + } + + @PostMapping("/{punishmentId}/tickets") + public ResponseEntity> modifyPunishmentTickets( + @PathVariable String punishmentId, + @RequestBody @Valid ModifyTicketsRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + PunishmentOperationResult result = punishmentMutationService.modifyPunishmentTickets( + server, + punishmentId, + new ModifyPunishmentTicketsRequest( + request.addTicketIds(), + request.removeTicketIds(), + request.modifyAssociatedTickets(), + request.issuerName(), + request.issuerId() + ) + ); + + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", result.message() + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); + } + + public record MinecraftCreatePunishmentRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String targetUuid, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @JsonProperty("type_ordinal") @Min(0) int typeOrdinal, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_REASON_MAX_LENGTH) String reason, + @Min(0) Long duration, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_DATA_MAX_ENTRIES) Map data, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_NOTES_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String> notes, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> attachedTicketIds, + @Size(max = RequestValidationLimits.PLAYER_SEVERITY_MAX_LENGTH) String severity, + @Size(max = RequestValidationLimits.PLAYER_STATUS_MAX_LENGTH) String status + ) { + } + + public record AcknowledgeRequest( + @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String playerUuid, + @Size(max = RequestValidationLimits.TIMESTAMP_MAX_LENGTH) String executedAt, + boolean success, + @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String errorMessage + ) { + } + + public record PardonRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_REASON_MAX_LENGTH) String reason, + @Size(max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH) String expectedType + ) { + } + + public record AddNoteRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @NotBlank @Size(max = RequestValidationLimits.PLAYER_NOTE_TEXT_MAX_LENGTH) String note + ) { + } + + public record AddEvidenceRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @NotBlank @Size(max = RequestValidationLimits.EVIDENCE_URL_MAX_LENGTH) String evidenceUrl + ) { + } + + public record ChangeDurationRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Min(0) Long newDuration + ) { + } + + public record ToggleOptionRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @NotBlank @Size(max = RequestValidationLimits.PLAYER_MODIFICATION_TYPE_MAX_LENGTH) String option, + boolean enabled + ) { + } + + public record StatWipeAcknowledgeRequest( + @NotBlank @Size(max = RequestValidationLimits.ID_MAX_LENGTH) String punishmentId, + @Size(max = RequestValidationLimits.LOG_SERVER_NAME_MAX_LENGTH) String serverName, + boolean success + ) { + } + + public record ModifyTicketsRequest( + @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, + @Size(max = RequestValidationLimits.PLAYER_ISSUER_ID_MAX_LENGTH) String issuerId, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> addTicketIds, + @Size(max = RequestValidationLimits.PLAYER_PUNISHMENT_TICKETS_MAX_ENTRIES) List<@Size(max = RequestValidationLimits.ID_MAX_LENGTH) String> removeTicketIds, + boolean modifyAssociatedTickets + ) { + } +} diff --git a/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java b/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java index 050409b..72f3592 100644 --- a/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java @@ -1,321 +1,321 @@ -package gg.modl.backend.player.controller; - -import gg.modl.backend.player.PlayerService; -import gg.modl.backend.player.dto.response.LinkedAccountResponse; -import gg.modl.backend.player.dto.response.PunishmentPreviewView; -import gg.modl.backend.player.dto.response.PunishmentResponse; -import gg.modl.backend.player.dto.response.PunishmentSearchResult; -import gg.modl.backend.player.service.AccountLinkingService; -import gg.modl.backend.player.service.PunishmentEvidenceService; -import gg.modl.backend.player.service.PunishmentLifecycleService; -import gg.modl.backend.player.service.PunishmentMutationService; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.replay.service.ReplayService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.proto.modl.v1.ActivePunishmentsResponse; -import gg.modl.proto.modl.v1.PanelAddEvidenceRequest; -import gg.modl.proto.modl.v1.PanelAddModificationRequest; -import gg.modl.proto.modl.v1.PanelAddPunishmentNoteRequest; -import gg.modl.proto.modl.v1.PanelCreatePunishmentRequest; -import gg.modl.proto.modl.v1.PanelFindAndLinkAccountsResponse; -import gg.modl.proto.modl.v1.PanelLinkedAccountsResponse; -import gg.modl.proto.modl.v1.PanelLinkedBansResponse; -import gg.modl.proto.modl.v1.PlayerDetailResponse; -import gg.modl.proto.modl.v1.PlayerReplaysResponse; -import gg.modl.proto.modl.v1.PlayerSearchResultsResponse; -import gg.modl.proto.modl.v1.SimpleResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_PLAYERS) -@RequiredArgsConstructor -@Validated -public class PanelPlayerController { - private static final SimpleResponse SUCCESS = SimpleResponse.newBuilder().setSuccess(true).build(); - - private final PlayerService playerService; - private final PunishmentQueryService punishmentQueryService; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentEvidenceService punishmentEvidenceService; - private final PunishmentMutationService punishmentMutationService; - private final AccountLinkingService accountLinkingService; - private final ReplayService replayService; - - @GetMapping - public ResponseEntity searchPlayers( - @RequestParam @Size(min = 2) String search, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerSearchResults( - playerService.searchPlayers(server, search))); - } - - @GetMapping("/{uuid}") - public ResponseEntity getPlayer( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerDetail( - playerService.getPlayerDetails(server, UUID.fromString(uuid)))); - } - - @PostMapping - public ResponseEntity createPlayer( - @RequestBody gg.modl.proto.modl.v1.CreatePlayerRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.createPlayer( - server, - UUID.fromString(createRequest.getMinecraftUuid()), - createRequest.getUsername() - ); - - return ResponseEntity.status(HttpStatus.CREATED).body(SUCCESS); - } - - @PostMapping("/{uuid}/usernames") - public ResponseEntity addUsername( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.AddPlayerUsernameRequest addRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.addUsername(server, UUID.fromString(uuid), addRequest.getUsername()); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/notes") - public ResponseEntity addNote( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.CreatePlayerNoteRequest addRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.addNote( - server, - UUID.fromString(uuid), - addRequest.getText(), - PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerName()), - PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerId()) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/ips") - public ResponseEntity addIp( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.AddPlayerIpRequest addRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - playerService.addIp(server, UUID.fromString(uuid), addRequest.getIpAddress()); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments") - public ResponseEntity createPunishment( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody PanelCreatePunishmentRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String email = RequestUtil.getSessionEmail(request); - - punishmentLifecycleService.validatePunishmentPermission(server, email, createRequest.getTypeOrdinal()); - punishmentLifecycleService.createPunishment( - server, - UUID.fromString(uuid), - PanelPlayerProtoMapper.fromCreatePunishment(createRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/modifications") - public ResponseEntity addModification( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody PanelAddModificationRequest modRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentMutationService.addModification( - server, - UUID.fromString(uuid), - punishmentId, - PanelPlayerProtoMapper.fromAddModification(modRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @GetMapping("/{uuid}/punishments/active") - public ResponseEntity getActivePunishments( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List punishments = punishmentQueryService.getActivePunishments( - server, - UUID.fromString(uuid) - ); - - return ResponseEntity.ok(PanelPlayerProtoMapper.toActivePunishments(punishments)); - } - - @GetMapping("/{uuid}/punishments/preview") - public ResponseEntity previewPunishment( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestParam int typeOrdinal, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PunishmentPreviewView preview = punishmentQueryService.previewPunishment(server, uuid, typeOrdinal); - return ResponseEntity.ok(PunishmentPreviewProtoMapper.toProto(preview)); - } - - @GetMapping("/punishments/{punishmentId}") - public ResponseEntity getPunishmentById( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPunishment( - punishmentQueryService.getPunishmentById(server, punishmentId))); - } - - @GetMapping("/punishments/search") - public ResponseEntity> searchPunishments( - @RequestParam @Size(min = 2) String q, - @RequestParam(defaultValue = "false") boolean activeOnly, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(punishmentQueryService.searchPunishments(server, q, activeOnly)); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/notes") - public ResponseEntity addPunishmentNote( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody PanelAddPunishmentNoteRequest noteRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentEvidenceService.addPunishmentNote( - server, - UUID.fromString(uuid), - punishmentId, - noteRequest.getText(), - PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerName()), - PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerId()) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/evidence") - public ResponseEntity addEvidence( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody PanelAddEvidenceRequest evidenceRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentEvidenceService.addEvidence( - server, - UUID.fromString(uuid), - punishmentId, - PanelPlayerProtoMapper.fromAddEvidence(evidenceRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @PostMapping("/{uuid}/punishments/{punishmentId}/tickets") - public ResponseEntity modifyPunishmentTickets( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @PathVariable String punishmentId, - @RequestBody gg.modl.proto.modl.v1.ModifyPunishmentTicketsRequest ticketRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - punishmentMutationService.modifyPunishmentTickets( - server, - UUID.fromString(uuid), - punishmentId, - PanelPlayerProtoMapper.fromModifyTickets(ticketRequest) - ); - return ResponseEntity.ok(SUCCESS); - } - - @GetMapping("/{uuid}/linked") - public ResponseEntity getLinkedAccounts( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List linkedAccounts = accountLinkingService.getLinkedAccounts( - server, - UUID.fromString(uuid) - ); - - return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedAccounts(linkedAccounts)); - } - - @GetMapping("/{uuid}/replays") - public ResponseEntity getPlayerReplays( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerReplays( - replayService.listPlayerReplays(server, uuid))); - } - - @GetMapping("/punishments/{punishmentId}/linked-bans") - public ResponseEntity getLinkedBans( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List> linkedBans = punishmentQueryService.getLinkedBansForParent(server, punishmentId); - return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedBans(linkedBans)); - } - - @PostMapping("/{uuid}/find-linked") - public ResponseEntity findAndLinkAccounts( - @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - AccountLinkingService.LinkingResult result = accountLinkingService.findAndLinkAccounts( - server, - UUID.fromString(uuid) - ); - - return ResponseEntity.ok(PanelPlayerProtoMapper.toFindAndLinkResult( - result.success(), - result.message(), - result.linkedAccountsFound() - )); - } -} +package gg.modl.backend.player.controller; + +import gg.modl.backend.player.PlayerService; +import gg.modl.backend.player.dto.response.LinkedAccountResponse; +import gg.modl.backend.player.dto.response.PunishmentPreviewView; +import gg.modl.backend.player.dto.response.PunishmentResponse; +import gg.modl.backend.player.dto.response.PunishmentSearchResult; +import gg.modl.backend.player.service.AccountLinkingService; +import gg.modl.backend.player.service.PunishmentEvidenceService; +import gg.modl.backend.player.service.PunishmentLifecycleService; +import gg.modl.backend.player.service.PunishmentMutationService; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.replay.service.ReplayService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.proto.modl.v1.ActivePunishmentsResponse; +import gg.modl.proto.modl.v1.PanelAddEvidenceRequest; +import gg.modl.proto.modl.v1.PanelAddModificationRequest; +import gg.modl.proto.modl.v1.PanelAddPunishmentNoteRequest; +import gg.modl.proto.modl.v1.PanelCreatePunishmentRequest; +import gg.modl.proto.modl.v1.PanelFindAndLinkAccountsResponse; +import gg.modl.proto.modl.v1.PanelLinkedAccountsResponse; +import gg.modl.proto.modl.v1.PanelLinkedBansResponse; +import gg.modl.proto.modl.v1.PlayerDetailResponse; +import gg.modl.proto.modl.v1.PlayerReplaysResponse; +import gg.modl.proto.modl.v1.PlayerSearchResultsResponse; +import gg.modl.proto.modl.v1.SimpleResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_PLAYERS) +@RequiredArgsConstructor +@Validated +public class PanelPlayerController { + private static final SimpleResponse SUCCESS = SimpleResponse.newBuilder().setSuccess(true).build(); + + private final PlayerService playerService; + private final PunishmentQueryService punishmentQueryService; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentEvidenceService punishmentEvidenceService; + private final PunishmentMutationService punishmentMutationService; + private final AccountLinkingService accountLinkingService; + private final ReplayService replayService; + + @GetMapping + public ResponseEntity searchPlayers( + @RequestParam @Size(min = 2) String search, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerSearchResults( + playerService.searchPlayers(server, search))); + } + + @GetMapping("/{uuid}") + public ResponseEntity getPlayer( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerDetail( + playerService.getPlayerDetails(server, UUID.fromString(uuid)))); + } + + @PostMapping + public ResponseEntity createPlayer( + @RequestBody gg.modl.proto.modl.v1.CreatePlayerRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.createPlayer( + server, + UUID.fromString(createRequest.getMinecraftUuid()), + createRequest.getUsername() + ); + + return ResponseEntity.status(HttpStatus.CREATED).body(SUCCESS); + } + + @PostMapping("/{uuid}/usernames") + public ResponseEntity addUsername( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody gg.modl.proto.modl.v1.AddPlayerUsernameRequest addRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.addUsername(server, UUID.fromString(uuid), addRequest.getUsername()); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/notes") + public ResponseEntity addNote( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody gg.modl.proto.modl.v1.CreatePlayerNoteRequest addRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.addNote( + server, + UUID.fromString(uuid), + addRequest.getText(), + PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerName()), + PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerId()) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/ips") + public ResponseEntity addIp( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody gg.modl.proto.modl.v1.AddPlayerIpRequest addRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + playerService.addIp(server, UUID.fromString(uuid), addRequest.getIpAddress()); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments") + public ResponseEntity createPunishment( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestBody PanelCreatePunishmentRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String email = RequestUtil.getSessionEmail(request); + + punishmentLifecycleService.validatePunishmentPermission(server, email, createRequest.getTypeOrdinal()); + punishmentLifecycleService.createPunishment( + server, + UUID.fromString(uuid), + PanelPlayerProtoMapper.fromCreatePunishment(createRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/modifications") + public ResponseEntity addModification( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody PanelAddModificationRequest modRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentMutationService.addModification( + server, + UUID.fromString(uuid), + punishmentId, + PanelPlayerProtoMapper.fromAddModification(modRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @GetMapping("/{uuid}/punishments/active") + public ResponseEntity getActivePunishments( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List punishments = punishmentQueryService.getActivePunishments( + server, + UUID.fromString(uuid) + ); + + return ResponseEntity.ok(PanelPlayerProtoMapper.toActivePunishments(punishments)); + } + + @GetMapping("/{uuid}/punishments/preview") + public ResponseEntity previewPunishment( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @RequestParam int typeOrdinal, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PunishmentPreviewView preview = punishmentQueryService.previewPunishment(server, uuid, typeOrdinal); + return ResponseEntity.ok(PunishmentPreviewProtoMapper.toProto(preview)); + } + + @GetMapping("/punishments/{punishmentId}") + public ResponseEntity getPunishmentById( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPunishment( + punishmentQueryService.getPunishmentById(server, punishmentId))); + } + + @GetMapping("/punishments/search") + public ResponseEntity> searchPunishments( + @RequestParam @Size(min = 2) String q, + @RequestParam(defaultValue = "false") boolean activeOnly, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(punishmentQueryService.searchPunishments(server, q, activeOnly)); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/notes") + public ResponseEntity addPunishmentNote( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody PanelAddPunishmentNoteRequest noteRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentEvidenceService.addPunishmentNote( + server, + UUID.fromString(uuid), + punishmentId, + noteRequest.getText(), + PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerName()), + PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerId()) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/evidence") + public ResponseEntity addEvidence( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody PanelAddEvidenceRequest evidenceRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentEvidenceService.addEvidence( + server, + UUID.fromString(uuid), + punishmentId, + PanelPlayerProtoMapper.fromAddEvidence(evidenceRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @PostMapping("/{uuid}/punishments/{punishmentId}/tickets") + public ResponseEntity modifyPunishmentTickets( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + @PathVariable String punishmentId, + @RequestBody gg.modl.proto.modl.v1.ModifyPunishmentTicketsRequest ticketRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + punishmentMutationService.modifyPunishmentTickets( + server, + UUID.fromString(uuid), + punishmentId, + PanelPlayerProtoMapper.fromModifyTickets(ticketRequest) + ); + return ResponseEntity.ok(SUCCESS); + } + + @GetMapping("/{uuid}/linked") + public ResponseEntity getLinkedAccounts( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List linkedAccounts = accountLinkingService.getLinkedAccounts( + server, + UUID.fromString(uuid) + ); + + return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedAccounts(linkedAccounts)); + } + + @GetMapping("/{uuid}/replays") + public ResponseEntity getPlayerReplays( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerReplays( + replayService.listPlayerReplays(server, uuid))); + } + + @GetMapping("/punishments/{punishmentId}/linked-bans") + public ResponseEntity getLinkedBans( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List> linkedBans = punishmentQueryService.getLinkedBansForParent(server, punishmentId); + return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedBans(linkedBans)); + } + + @PostMapping("/{uuid}/find-linked") + public ResponseEntity findAndLinkAccounts( + @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + AccountLinkingService.LinkingResult result = accountLinkingService.findAndLinkAccounts( + server, + UUID.fromString(uuid) + ); + + return ResponseEntity.ok(PanelPlayerProtoMapper.toFindAndLinkResult( + result.success(), + result.message(), + result.linkedAccountsFound() + )); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/IPEntry.java b/src/main/java/gg/modl/backend/player/data/IPEntry.java index af7baf5..8dc674e 100644 --- a/src/main/java/gg/modl/backend/player/data/IPEntry.java +++ b/src/main/java/gg/modl/backend/player/data/IPEntry.java @@ -1,39 +1,39 @@ -package gg.modl.backend.player.data; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class IPEntry { - @Nullable - private String ipAddress; - - @Nullable - private String country; - - @Nullable - private String region; - - @Nullable - private String asn; - - private boolean proxy; - - private boolean hosting; - - @NotNull - private Date firstLogin; - - @Builder.Default - private List logins = new ArrayList<>(); -} +package gg.modl.backend.player.data; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class IPEntry { + @Nullable + private String ipAddress; + + @Nullable + private String country; + + @Nullable + private String region; + + @Nullable + private String asn; + + private boolean proxy; + + private boolean hosting; + + @NotNull + private Date firstLogin; + + @Builder.Default + private List logins = new ArrayList<>(); +} diff --git a/src/main/java/gg/modl/backend/player/data/NoteEntry.java b/src/main/java/gg/modl/backend/player/data/NoteEntry.java index 3f8e789..72aa6a8 100644 --- a/src/main/java/gg/modl/backend/player/data/NoteEntry.java +++ b/src/main/java/gg/modl/backend/player/data/NoteEntry.java @@ -1,24 +1,24 @@ -package gg.modl.backend.player.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.mongodb.core.mapping.Field; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class NoteEntry { - @Field("id") - private String id; - - private String text; - private Date date; - private String issuerName; - private String issuerId; -} +package gg.modl.backend.player.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.mongodb.core.mapping.Field; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class NoteEntry { + @Field("id") + private String id; + + private String text; + private Date date; + private String issuerName; + private String issuerId; +} diff --git a/src/main/java/gg/modl/backend/player/data/UsernameEntry.java b/src/main/java/gg/modl/backend/player/data/UsernameEntry.java index ccd200a..58fc224 100644 --- a/src/main/java/gg/modl/backend/player/data/UsernameEntry.java +++ b/src/main/java/gg/modl/backend/player/data/UsernameEntry.java @@ -1,7 +1,7 @@ -package gg.modl.backend.player.data; - - -import java.util.Date; -import org.jetbrains.annotations.NotNull; - +package gg.modl.backend.player.data; + + +import java.util.Date; +import org.jetbrains.annotations.NotNull; + public record UsernameEntry(@NotNull String username, @NotNull Date date) {} \ No newline at end of file diff --git a/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java b/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java index c95065b..ecffb4f 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/Punishment.java @@ -1,73 +1,73 @@ -package gg.modl.backend.player.data.punishment; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.mongodb.core.mapping.Field; -import org.springframework.data.mongodb.core.mapping.FieldType; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "DATA_LINKED_BAN_ID", path = "data.linkedBanId"), - @MongoFieldAlias(name = "DATA_LINKED_BAN_PARENT_UUID", path = "data.linkedBanParentUuid"), - @MongoFieldAlias(name = "DATA_STATUS", path = "data.status"), - @MongoFieldAlias(name = "DATA_REASON", path = "data.reason"), - @MongoFieldAlias(name = "DATA_DURATION", path = "data.duration"), - @MongoFieldAlias(name = "DATA_SEVERITY", path = "data.severity"), - @MongoFieldAlias(name = "DATA_ALT_BLOCKING", path = "data.altBlocking"), - @MongoFieldAlias(name = "DATA_WIPE_AFTER_EXPIRY", path = "data.wipeAfterExpiry"), - @MongoFieldAlias(name = "DATA_STAT_WIPE_COMPLETED", path = "data.statWipeCompleted"), - @MongoFieldAlias(name = "DATA_BLOCKED_NAME", path = "data.blockedName"), - @MongoFieldAlias(name = "DATA_BLOCKED_SKIN", path = "data.blockedSkin"), - @MongoFieldAlias(name = "DATA_OFFENSE_LEVEL", path = "data.offenseLevel") -}) -public class Punishment { - public static final int LINKED_BAN_TYPE_ORDINAL = 4; - - @NotNull - @Field(value = "id", targetType = FieldType.STRING) - private String id; - - @Field("typeOrdinal") - private int typeOrdinal; - - @Nullable - private String issuerName; - - @Nullable - private String issuerId; - - @NotNull - private Date issued; - - @Nullable - private Date started; - - @NotNull - private List modifications = new ArrayList<>(); - - @NotNull - private List notes = new ArrayList<>(); - - @NotNull - private List evidence = new ArrayList<>(); - - @NotNull - private List attachedTicketIds = new ArrayList<>(); - - @Nullable - private Map data = new HashMap<>(); -} +package gg.modl.backend.player.data.punishment; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldType; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "DATA_LINKED_BAN_ID", path = "data.linkedBanId"), + @MongoFieldAlias(name = "DATA_LINKED_BAN_PARENT_UUID", path = "data.linkedBanParentUuid"), + @MongoFieldAlias(name = "DATA_STATUS", path = "data.status"), + @MongoFieldAlias(name = "DATA_REASON", path = "data.reason"), + @MongoFieldAlias(name = "DATA_DURATION", path = "data.duration"), + @MongoFieldAlias(name = "DATA_SEVERITY", path = "data.severity"), + @MongoFieldAlias(name = "DATA_ALT_BLOCKING", path = "data.altBlocking"), + @MongoFieldAlias(name = "DATA_WIPE_AFTER_EXPIRY", path = "data.wipeAfterExpiry"), + @MongoFieldAlias(name = "DATA_STAT_WIPE_COMPLETED", path = "data.statWipeCompleted"), + @MongoFieldAlias(name = "DATA_BLOCKED_NAME", path = "data.blockedName"), + @MongoFieldAlias(name = "DATA_BLOCKED_SKIN", path = "data.blockedSkin"), + @MongoFieldAlias(name = "DATA_OFFENSE_LEVEL", path = "data.offenseLevel") +}) +public class Punishment { + public static final int LINKED_BAN_TYPE_ORDINAL = 4; + + @NotNull + @Field(value = "id", targetType = FieldType.STRING) + private String id; + + @Field("typeOrdinal") + private int typeOrdinal; + + @Nullable + private String issuerName; + + @Nullable + private String issuerId; + + @NotNull + private Date issued; + + @Nullable + private Date started; + + @NotNull + private List modifications = new ArrayList<>(); + + @NotNull + private List notes = new ArrayList<>(); + + @NotNull + private List evidence = new ArrayList<>(); + + @NotNull + private List attachedTicketIds = new ArrayList<>(); + + @Nullable + private Map data = new HashMap<>(); +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java index 0a1109c..2a0807b 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentEvidence.java @@ -1,18 +1,18 @@ -package gg.modl.backend.player.data.punishment; - -import java.util.Date; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -public record PunishmentEvidence( - @Nullable String text, - @Nullable String url, - @NotNull String type, - @Nullable String uploadedBy, - @Nullable String uploadedById, - @NotNull Date uploadedAt, - @Nullable String fileName, - @Nullable String fileType, - @Nullable Long fileSize -) { -} +package gg.modl.backend.player.data.punishment; + +import java.util.Date; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public record PunishmentEvidence( + @Nullable String text, + @Nullable String url, + @NotNull String type, + @Nullable String uploadedBy, + @Nullable String uploadedById, + @NotNull Date uploadedAt, + @Nullable String fileName, + @Nullable String fileType, + @Nullable Long fileSize +) { +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java index 813a4bc..5d058dc 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModification.java @@ -1,20 +1,20 @@ -package gg.modl.backend.player.data.punishment; - -import java.util.Date; -import java.util.Map; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.mongodb.core.mapping.Field; - -public record PunishmentModification( - @NotNull @Field("id") String id, - @NotNull String type, - @NotNull Date date, - @Nullable String issuerName, - @Nullable String issuerId, - @NotNull String reason, - @Nullable Long effectiveDuration, - @Nullable String appealTicketId, - @Nullable Map data -) { -} +package gg.modl.backend.player.data.punishment; + +import java.util.Date; +import java.util.Map; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.mongodb.core.mapping.Field; + +public record PunishmentModification( + @NotNull @Field("id") String id, + @NotNull String type, + @NotNull Date date, + @Nullable String issuerName, + @Nullable String issuerId, + @NotNull String reason, + @Nullable Long effectiveDuration, + @Nullable String appealTicketId, + @Nullable Map data +) { +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java index 93de723..2cf083e 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentNote.java @@ -1,15 +1,15 @@ -package gg.modl.backend.player.data.punishment; - -import java.util.Date; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.mongodb.core.mapping.Field; - -public record PunishmentNote( - @NotNull @Field("id") String id, - @NotNull String text, - @NotNull Date date, - @Nullable String issuerName, - @Nullable String issuerId -) { -} +package gg.modl.backend.player.data.punishment; + +import java.util.Date; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.mongodb.core.mapping.Field; + +public record PunishmentNote( + @NotNull @Field("id") String id, + @NotNull String text, + @NotNull Date date, + @Nullable String issuerName, + @Nullable String issuerId +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java b/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java index 53a504c..dbb226a 100644 --- a/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java +++ b/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java @@ -1,11 +1,11 @@ -package gg.modl.backend.player.dto.request; - -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; - -public record CreatePlayerRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username -) { -} +package gg.modl.backend.player.dto.request; + +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record CreatePlayerRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @NotBlank @Pattern(regexp = RegExpConstants.MINECRAFT_USERNAME) String username +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java index 75ea83b..c7dcd5f 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java +++ b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountResponse.java @@ -1,13 +1,13 @@ -package gg.modl.backend.player.dto.response; - -import java.util.Date; -import org.jetbrains.annotations.Nullable; - -public record LinkedAccountResponse( - String minecraftUuid, - String username, - int activeBans, - int activeMutes, - @Nullable Date lastLinkedUpdate -) { -} +package gg.modl.backend.player.dto.response; + +import java.util.Date; +import org.jetbrains.annotations.Nullable; + +public record LinkedAccountResponse( + String minecraftUuid, + String username, + int activeBans, + int activeMutes, + @Nullable Date lastLinkedUpdate +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java index c786de2..8027d6d 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerDetailResponse.java @@ -1,26 +1,26 @@ -package gg.modl.backend.player.dto.response; - -import gg.modl.backend.player.data.IPEntry; -import gg.modl.backend.player.data.NoteEntry; -import gg.modl.backend.player.data.UsernameEntry; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.Nullable; - -public record PlayerDetailResponse( - String id, - String minecraftUuid, - List usernames, - List notes, - List ipAddresses, - List punishments, - Map data, - String social, - String gameplay, - int socialPoints, - int gameplayPoints, - @Nullable IPEntry latestIPData, - @Nullable String lastServer, - double playtimeHours -) { -} +package gg.modl.backend.player.dto.response; + +import gg.modl.backend.player.data.IPEntry; +import gg.modl.backend.player.data.NoteEntry; +import gg.modl.backend.player.data.UsernameEntry; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record PlayerDetailResponse( + String id, + String minecraftUuid, + List usernames, + List notes, + List ipAddresses, + List punishments, + Map data, + String social, + String gameplay, + int socialPoints, + int gameplayPoints, + @Nullable IPEntry latestIPData, + @Nullable String lastServer, + double playtimeHours +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java index 7907ea9..cfa942f 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerSearchResult.java @@ -1,12 +1,12 @@ -package gg.modl.backend.player.dto.response; - -import java.util.Date; - -public record PlayerSearchResult( - String uuid, - String username, - String status, - Date lastOnline, - boolean isOnline -) { -} +package gg.modl.backend.player.dto.response; + +import java.util.Date; + +public record PlayerSearchResult( + String uuid, + String username, + String status, + Date lastOnline, + boolean isOnline +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java b/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java index 63af9c2..b2174a5 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PunishmentSearchResult.java @@ -1,12 +1,12 @@ -package gg.modl.backend.player.dto.response; - -import java.util.Date; - -public record PunishmentSearchResult( - String id, - String playerName, - int typeOrdinal, - String status, - Date issued -) { -} +package gg.modl.backend.player.dto.response; + +import java.util.Date; + +public record PunishmentSearchResult( + String id, + String playerName, + int typeOrdinal, + String status, + Date issued +) { +} diff --git a/src/main/java/gg/modl/backend/player/service/MojangApiService.java b/src/main/java/gg/modl/backend/player/service/MojangApiService.java index 8935bd7..948bf9d 100644 --- a/src/main/java/gg/modl/backend/player/service/MojangApiService.java +++ b/src/main/java/gg/modl/backend/player/service/MojangApiService.java @@ -1,101 +1,101 @@ -package gg.modl.backend.player.service; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.Optional; -import java.util.UUID; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@Slf4j -public class MojangApiService { - - private final HttpClient httpClient; - private static final String PROFILE_BY_NAME_URL = "https://api.mojang.com/users/profiles/minecraft/"; - private static final String PROFILE_BY_UUID_URL = "https://sessionserver.mojang.com/session/minecraft/profile/"; - private static final Pattern NAME_PATTERN = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\""); - private static final Pattern ID_PATTERN = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\""); - - public MojangApiService() { - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .build(); - } - - public Optional lookupByUsername(String username) { - try { - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(PROFILE_BY_NAME_URL + username)) - .timeout(Duration.ofSeconds(5)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { - return Optional.empty(); - } - - return parseProfile(response.body()); - } catch (Exception e) { - log.warn("Mojang API lookup by username '{}' failed", username, e); - return Optional.empty(); - } - } - - private Optional parseProfile(String json) { - Matcher nameMatcher = NAME_PATTERN.matcher(json); - Matcher idMatcher = ID_PATTERN.matcher(json); - - if (!nameMatcher.find() || !idMatcher.find()) { - return Optional.empty(); - } - - String name = nameMatcher.group(1); - String rawId = idMatcher.group(1); - UUID uuid = fromDashlessUuid(rawId); - - return Optional.of(new MojangProfile(name, uuid)); - } - - private static UUID fromDashlessUuid(String id) { - if (id.contains("-")) { - return UUID.fromString(id); - } - String withDashes = id.replaceFirst( - "(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", - "$1-$2-$3-$4-$5" - ); - return UUID.fromString(withDashes); - } - - public Optional lookupByUuid(String uuid) { - try { - String dashlessUuid = uuid.replace("-", ""); - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(PROFILE_BY_UUID_URL + dashlessUuid)) - .timeout(Duration.ofSeconds(5)) - .GET() - .build(); - - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { - return Optional.empty(); - } - - return parseProfile(response.body()); - } catch (Exception e) { - log.warn("Mojang API lookup by UUID '{}' failed", uuid, e); - return Optional.empty(); - } - } - - public record MojangProfile(String name, UUID uuid) {} -} +package gg.modl.backend.player.service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class MojangApiService { + + private final HttpClient httpClient; + private static final String PROFILE_BY_NAME_URL = "https://api.mojang.com/users/profiles/minecraft/"; + private static final String PROFILE_BY_UUID_URL = "https://sessionserver.mojang.com/session/minecraft/profile/"; + private static final Pattern NAME_PATTERN = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\""); + private static final Pattern ID_PATTERN = Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\""); + + public MojangApiService() { + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + } + + public Optional lookupByUsername(String username) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(PROFILE_BY_NAME_URL + username)) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { + return Optional.empty(); + } + + return parseProfile(response.body()); + } catch (Exception e) { + log.warn("Mojang API lookup by username '{}' failed", username, e); + return Optional.empty(); + } + } + + private Optional parseProfile(String json) { + Matcher nameMatcher = NAME_PATTERN.matcher(json); + Matcher idMatcher = ID_PATTERN.matcher(json); + + if (!nameMatcher.find() || !idMatcher.find()) { + return Optional.empty(); + } + + String name = nameMatcher.group(1); + String rawId = idMatcher.group(1); + UUID uuid = fromDashlessUuid(rawId); + + return Optional.of(new MojangProfile(name, uuid)); + } + + private static UUID fromDashlessUuid(String id) { + if (id.contains("-")) { + return UUID.fromString(id); + } + String withDashes = id.replaceFirst( + "(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", + "$1-$2-$3-$4-$5" + ); + return UUID.fromString(withDashes); + } + + public Optional lookupByUuid(String uuid) { + try { + String dashlessUuid = uuid.replace("-", ""); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(PROFILE_BY_UUID_URL + dashlessUuid)) + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200 || response.body() == null || response.body().isBlank()) { + return Optional.empty(); + } + + return parseProfile(response.body()); + } catch (Exception e) { + log.warn("Mojang API lookup by UUID '{}' failed", uuid, e); + return Optional.empty(); + } + } + + public record MojangProfile(String name, UUID uuid) {} +} diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java b/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java index 4e9ebac..bca836d 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java @@ -1,169 +1,169 @@ -package gg.modl.backend.player.service; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.validation.SafeUrls; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentEvidence; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.player.dto.request.AddEvidenceRequest; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.player.service.PunishmentQueryService.UploadedEvidenceItem; -import gg.modl.backend.server.data.Server; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import gg.modl.backend.infrastructure.util.IdGenerator; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PunishmentEvidenceService { - private final PlayerMongoRepository playerRepository; - private final PunishmentMongoRepository punishmentRepository; - private final PunishmentQueryService punishmentQueryService; - private final PunishmentRealtimePublisher realtimePublisher; - - public PunishmentOperationResult addEvidence(Server server, String punishmentId, String evidenceUrl, String issuerName, String issuerId) { - SafeUrls.requireSafe(evidenceUrl, "Invalid evidence URL"); - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - Date now = new Date(); - PunishmentEvidence evidence = new PunishmentEvidence( - null, - evidenceUrl, - "url", - resolvedIssuerName, - issuerId, - now, - null, - null, - null - ); - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - "added evidence", - now, - resolvedIssuerName, - issuerId - ); - context.punishment().getEvidence().add(evidence); - context.punishment().getNotes().add(note); - punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, - List.of(evidence), note); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence added", true, 1); - } - - public Player addEvidence(Server server, UUID playerUuid, String punishmentId, AddEvidenceRequest request) { - SafeUrls.requireSafe(request.url(), "Invalid evidence URL"); - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Punishment punishment = findPunishment(player, punishmentId); - if (punishment == null) { - throw new ResourceNotFoundException("Punishment not found"); - } - - String evIssuerId = request.issuerId(); - String evIssuerName = evIssuerId != null ? null : (request.issuerName() != null ? request.issuerName() : "System"); - - PunishmentEvidence evidence = new PunishmentEvidence( - request.text(), - request.url(), - request.type(), - evIssuerName, - evIssuerId, - new Date(), - request.fileName(), - request.fileType(), - request.fileSize() - ); - punishment.getEvidence().add(evidence); - punishmentRepository.appendEvidence(server, player.getMinecraftUuid().toString(), punishmentId, List.of(evidence), null); - realtimePublisher.punishmentDetailsChanged(server, player, punishment); - return player; - } - - private Punishment findPunishment(Player player, String punishmentId) { - return PunishmentQueryService.findPunishment(player, punishmentId); - } - - public PunishmentOperationResult addUploadedEvidence(Server server, String punishmentId, String issuerName, String issuerId, List evidenceItems) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - Date now = new Date(); - List evidenceList = new ArrayList<>(); - for (UploadedEvidenceItem evidenceItem : evidenceItems) { - PunishmentEvidence evidence = new PunishmentEvidence( - null, - evidenceItem.url(), - "file", - resolvedIssuerName, - issuerId, - now, - evidenceItem.fileName(), - evidenceItem.fileType(), - evidenceItem.fileSize() - ); - evidenceList.add(evidence); - context.punishment().getEvidence().add(evidence); - } - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - "uploaded " + evidenceItems.size() + " evidence file(s)", - now, - resolvedIssuerName, - issuerId - ); - context.punishment().getNotes().add(note); - punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, evidenceList, note); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence uploaded successfully", true, evidenceItems.size()); - } - - public PunishmentOperationResult addPunishmentNote(Server server, String punishmentId, String text, String issuerName, String issuerId) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); - context.punishment().getNotes().add(note); - punishmentRepository.addPunishmentNote(server, context.player().getMinecraftUuid().toString(), punishmentId, note, null); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Note added", true, 1); - } - - public Player addPunishmentNote(Server server, UUID playerUuid, String punishmentId, String text, String issuerName, String issuerId) { - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Punishment punishment = findPunishment(player, punishmentId); - if (punishment == null) { - throw new ResourceNotFoundException("Punishment not found"); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); - punishment.getNotes().add(note); - punishmentRepository.addPunishmentNote(server, player.getMinecraftUuid().toString(), punishmentId, note, null); - realtimePublisher.punishmentDetailsChanged(server, player, punishment); - return player; - } -} +package gg.modl.backend.player.service; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.validation.SafeUrls; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentEvidence; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.dto.request.AddEvidenceRequest; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.player.service.PunishmentQueryService.UploadedEvidenceItem; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import gg.modl.backend.infrastructure.util.IdGenerator; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PunishmentEvidenceService { + private final PlayerMongoRepository playerRepository; + private final PunishmentMongoRepository punishmentRepository; + private final PunishmentQueryService punishmentQueryService; + private final PunishmentRealtimePublisher realtimePublisher; + + public PunishmentOperationResult addEvidence(Server server, String punishmentId, String evidenceUrl, String issuerName, String issuerId) { + SafeUrls.requireSafe(evidenceUrl, "Invalid evidence URL"); + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = issuerId != null ? null : issuerName; + Date now = new Date(); + PunishmentEvidence evidence = new PunishmentEvidence( + null, + evidenceUrl, + "url", + resolvedIssuerName, + issuerId, + now, + null, + null, + null + ); + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + "added evidence", + now, + resolvedIssuerName, + issuerId + ); + context.punishment().getEvidence().add(evidence); + context.punishment().getNotes().add(note); + punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, + List.of(evidence), note); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence added", true, 1); + } + + public Player addEvidence(Server server, UUID playerUuid, String punishmentId, AddEvidenceRequest request) { + SafeUrls.requireSafe(request.url(), "Invalid evidence URL"); + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Punishment punishment = findPunishment(player, punishmentId); + if (punishment == null) { + throw new ResourceNotFoundException("Punishment not found"); + } + + String evIssuerId = request.issuerId(); + String evIssuerName = evIssuerId != null ? null : (request.issuerName() != null ? request.issuerName() : "System"); + + PunishmentEvidence evidence = new PunishmentEvidence( + request.text(), + request.url(), + request.type(), + evIssuerName, + evIssuerId, + new Date(), + request.fileName(), + request.fileType(), + request.fileSize() + ); + punishment.getEvidence().add(evidence); + punishmentRepository.appendEvidence(server, player.getMinecraftUuid().toString(), punishmentId, List.of(evidence), null); + realtimePublisher.punishmentDetailsChanged(server, player, punishment); + return player; + } + + private Punishment findPunishment(Player player, String punishmentId) { + return PunishmentQueryService.findPunishment(player, punishmentId); + } + + public PunishmentOperationResult addUploadedEvidence(Server server, String punishmentId, String issuerName, String issuerId, List evidenceItems) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = issuerId != null ? null : issuerName; + Date now = new Date(); + List evidenceList = new ArrayList<>(); + for (UploadedEvidenceItem evidenceItem : evidenceItems) { + PunishmentEvidence evidence = new PunishmentEvidence( + null, + evidenceItem.url(), + "file", + resolvedIssuerName, + issuerId, + now, + evidenceItem.fileName(), + evidenceItem.fileType(), + evidenceItem.fileSize() + ); + evidenceList.add(evidence); + context.punishment().getEvidence().add(evidence); + } + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + "uploaded " + evidenceItems.size() + " evidence file(s)", + now, + resolvedIssuerName, + issuerId + ); + context.punishment().getNotes().add(note); + punishmentRepository.appendEvidence(server, context.player().getMinecraftUuid().toString(), punishmentId, evidenceList, note); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Evidence uploaded successfully", true, evidenceItems.size()); + } + + public PunishmentOperationResult addPunishmentNote(Server server, String punishmentId, String text, String issuerName, String issuerId) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = issuerId != null ? null : issuerName; + PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); + context.punishment().getNotes().add(note); + punishmentRepository.addPunishmentNote(server, context.player().getMinecraftUuid().toString(), punishmentId, note, null); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Note added", true, 1); + } + + public Player addPunishmentNote(Server server, UUID playerUuid, String punishmentId, String text, String issuerName, String issuerId) { + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Punishment punishment = findPunishment(player, punishmentId); + if (punishment == null) { + throw new ResourceNotFoundException("Punishment not found"); + } + + String resolvedIssuerName = issuerId != null ? null : issuerName; + PunishmentNote note = new PunishmentNote(IdGenerator.generateShortId(), text, new Date(), resolvedIssuerName, issuerId); + punishment.getNotes().add(note); + punishmentRepository.addPunishmentNote(server, player.getMinecraftUuid().toString(), punishmentId, note, null); + realtimePublisher.punishmentDetailsChanged(server, player, punishment); + return player; + } +} diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java b/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java index ce7021e..9e6684b 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java @@ -1,198 +1,198 @@ -package gg.modl.backend.player.service; - -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentData; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeIndex; -import java.util.Collections; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import org.jetbrains.annotations.Nullable; - -public final class PunishmentMapper { - - private PunishmentMapper() {} - - public static Map toPunishmentMap(Punishment punishment, List punishmentTypes) { - return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), Collections.emptyMap()); - } - - public static Map toPunishmentMap(Punishment punishment, List punishmentTypes, Map resolvedIssuers) { - return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), resolvedIssuers); - } - - public static Map toPunishmentMap(Punishment punishment, Map typesByOrdinal, Map resolvedIssuers) { - Map map = new LinkedHashMap<>(); - map.put("id", punishment.getId()); - map.put("issuerName", resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers)); - map.put("issued", punishment.getIssued()); - map.put("started", punishment.getStarted()); - - int ordinal = punishment.getTypeOrdinal(); - map.put("typeOrdinal", ordinal); - - PunishmentType matchedType = typesByOrdinal.get(ordinal); - String actualTypeName = matchedType != null ? matchedType.getName() : null; - - map.put("type", actualTypeName != null ? actualTypeName : "Unknown"); - - Map dataWithTypeName = punishment.getData() != null ? - new LinkedHashMap<>(punishment.getData()) : new LinkedHashMap<>(); - if (actualTypeName != null) { - dataWithTypeName.put("typeName", actualTypeName); - } - - List> modifications = punishment.getModifications() - .stream() - .map(m -> { - Map mod = new LinkedHashMap<>(); - mod.put("id", m.id()); - mod.put("type", m.type()); - mod.put("date", m.date()); - mod.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); - mod.put("effectiveDuration", m.effectiveDuration()); - mod.put("data", m.data()); - return mod; - }).toList(); - map.put("modifications", modifications); - - List> notes = punishment.getNotes() - .stream() - .map(n -> { - Map note = new LinkedHashMap<>(); - note.put("id", n.id()); - note.put("text", n.text()); - note.put("issuerName", resolveIssuer(n.issuerId(), n.issuerName(), resolvedIssuers)); - note.put("date", n.date()); - return note; - }).toList(); - map.put("notes", notes); - - List> evidence = punishment.getEvidence() - .stream() - .map(e -> { - Map ev = new LinkedHashMap<>(); - ev.put("text", e.text()); - ev.put("url", e.url()); - ev.put("type", e.type()); - ev.put("uploadedBy", resolveIssuer(e.uploadedById(), e.uploadedBy(), resolvedIssuers)); - ev.put("uploadedAt", e.uploadedAt()); - ev.put("fileName", e.fileName()); - ev.put("fileType", e.fileType()); - ev.put("fileSize", e.fileSize()); - return ev; - }).toList(); - map.put("evidence", evidence); - - map.put("attachedTicketIds", punishment.getAttachedTicketIds()); - map.put("data", dataWithTypeName); - - return map; - } - - static String resolveIssuer(@Nullable String issuerId, @Nullable String issuerName, Map resolvedIssuers) { - if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { - return resolvedIssuers.get(issuerId); - } - if (issuerName != null) { - return issuerName; - } - return issuerId != null ? "Unknown Staff" : "Console"; - } - - public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator) { - return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, Collections.emptyMap()); - } - - public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { - return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, resolvedIssuers); - } - - public static Map toSimplePunishment(Punishment punishment, Map typesByOrdinal, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { - Map data = punishment.getData(); - Date expires = statusCalculator.getEffectiveExpiry(punishment); - - PunishmentType punishmentType = typesByOrdinal.get(punishment.getTypeOrdinal()); - - String typeName = punishmentType != null ? punishmentType.getName() : "Unknown"; - String playerDescription = punishmentType != null ? punishmentType.getPlayerDescription() : null; - - String effectiveCategory = statusCalculator.getEffectiveCategory(punishmentType, data); - String category = effectiveCategory != null ? effectiveCategory : "OTHER"; - - String reason = PunishmentData.getReason(data); - if ((reason == null || reason.isBlank()) && punishment.getNotes() != null) { - for (PunishmentNote note : punishment.getNotes()) { - String noteText = note.text(); - if (noteText != null && !isAutoGeneratedNote(noteText)) { - reason = noteText; - break; - } - } - } - - Map result = new LinkedHashMap<>(); - result.put("id", punishment.getId()); - result.put("type", typeName); - result.put("category", category); - result.put("typeOrdinal", punishment.getTypeOrdinal()); - result.put("ordinal", punishment.getTypeOrdinal()); - result.put("started", punishment.getStarted() != null); - result.put("expiration", expires != null ? expires.getTime() : null); - result.put("description", reason != null && !reason.isBlank() ? reason : "No reason specified"); - result.put("issuerName", resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers)); - result.put("issuedAt", punishment.getIssued().getTime()); - result.put("playerDescription", playerDescription); - result.put("modifications", punishment.getModifications() - .stream().map(m -> { - Map modMap = new LinkedHashMap<>(); - modMap.put("type", m.type()); - modMap.put("timestamp", m.date() != null ? m.date().getTime() : null); - modMap.put("effectiveDuration", m.effectiveDuration() != null ? m.effectiveDuration() : 0L); - modMap.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); - return modMap; - }).toList()); - - return result; - } - - public static boolean isAutoGeneratedNote(String noteText) { - if (noteText == null) { - return true; - } - String lower = noteText.toLowerCase(); - return lower.equals("issued punishment") || - lower.startsWith("issued ") || - lower.equals("pardoned punishment") || - lower.equals("added evidence") || - lower.startsWith("changed duration to ") || - lower.startsWith("enabled ") || - lower.startsWith("disabled "); - } - - public static String formatDuration(long durationMs, boolean isPermanent) { - if (isPermanent || durationMs < 0) { - return "Permanent"; - } - if (durationMs == 0) { - return "Instant"; - } - - long totalSeconds = durationMs / 1000; - long days = totalSeconds / 86400; - long hours = (totalSeconds % 86400) / 3600; - long minutes = (totalSeconds % 3600) / 60; - long seconds = totalSeconds % 60; - - StringBuilder sb = new StringBuilder(); - if (days > 0) sb.append(days).append("d"); - if (hours > 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(hours).append("h"); } - if (minutes > 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(minutes).append("m"); } - if (seconds > 0 && days == 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(seconds).append("s"); } - - return sb.isEmpty() ? "0s" : sb.toString(); - } -} +package gg.modl.backend.player.service; + +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeIndex; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public final class PunishmentMapper { + + private PunishmentMapper() {} + + public static Map toPunishmentMap(Punishment punishment, List punishmentTypes) { + return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), Collections.emptyMap()); + } + + public static Map toPunishmentMap(Punishment punishment, List punishmentTypes, Map resolvedIssuers) { + return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), resolvedIssuers); + } + + public static Map toPunishmentMap(Punishment punishment, Map typesByOrdinal, Map resolvedIssuers) { + Map map = new LinkedHashMap<>(); + map.put("id", punishment.getId()); + map.put("issuerName", resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers)); + map.put("issued", punishment.getIssued()); + map.put("started", punishment.getStarted()); + + int ordinal = punishment.getTypeOrdinal(); + map.put("typeOrdinal", ordinal); + + PunishmentType matchedType = typesByOrdinal.get(ordinal); + String actualTypeName = matchedType != null ? matchedType.getName() : null; + + map.put("type", actualTypeName != null ? actualTypeName : "Unknown"); + + Map dataWithTypeName = punishment.getData() != null ? + new LinkedHashMap<>(punishment.getData()) : new LinkedHashMap<>(); + if (actualTypeName != null) { + dataWithTypeName.put("typeName", actualTypeName); + } + + List> modifications = punishment.getModifications() + .stream() + .map(m -> { + Map mod = new LinkedHashMap<>(); + mod.put("id", m.id()); + mod.put("type", m.type()); + mod.put("date", m.date()); + mod.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); + mod.put("effectiveDuration", m.effectiveDuration()); + mod.put("data", m.data()); + return mod; + }).toList(); + map.put("modifications", modifications); + + List> notes = punishment.getNotes() + .stream() + .map(n -> { + Map note = new LinkedHashMap<>(); + note.put("id", n.id()); + note.put("text", n.text()); + note.put("issuerName", resolveIssuer(n.issuerId(), n.issuerName(), resolvedIssuers)); + note.put("date", n.date()); + return note; + }).toList(); + map.put("notes", notes); + + List> evidence = punishment.getEvidence() + .stream() + .map(e -> { + Map ev = new LinkedHashMap<>(); + ev.put("text", e.text()); + ev.put("url", e.url()); + ev.put("type", e.type()); + ev.put("uploadedBy", resolveIssuer(e.uploadedById(), e.uploadedBy(), resolvedIssuers)); + ev.put("uploadedAt", e.uploadedAt()); + ev.put("fileName", e.fileName()); + ev.put("fileType", e.fileType()); + ev.put("fileSize", e.fileSize()); + return ev; + }).toList(); + map.put("evidence", evidence); + + map.put("attachedTicketIds", punishment.getAttachedTicketIds()); + map.put("data", dataWithTypeName); + + return map; + } + + static String resolveIssuer(@Nullable String issuerId, @Nullable String issuerName, Map resolvedIssuers) { + if (issuerId != null && resolvedIssuers.containsKey(issuerId)) { + return resolvedIssuers.get(issuerId); + } + if (issuerName != null) { + return issuerName; + } + return issuerId != null ? "Unknown Staff" : "Console"; + } + + public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator) { + return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, Collections.emptyMap()); + } + + public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { + return toSimplePunishment(punishment, PunishmentTypeIndex.byOrdinal(types), statusCalculator, resolvedIssuers); + } + + public static Map toSimplePunishment(Punishment punishment, Map typesByOrdinal, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { + Map data = punishment.getData(); + Date expires = statusCalculator.getEffectiveExpiry(punishment); + + PunishmentType punishmentType = typesByOrdinal.get(punishment.getTypeOrdinal()); + + String typeName = punishmentType != null ? punishmentType.getName() : "Unknown"; + String playerDescription = punishmentType != null ? punishmentType.getPlayerDescription() : null; + + String effectiveCategory = statusCalculator.getEffectiveCategory(punishmentType, data); + String category = effectiveCategory != null ? effectiveCategory : "OTHER"; + + String reason = PunishmentData.getReason(data); + if ((reason == null || reason.isBlank()) && punishment.getNotes() != null) { + for (PunishmentNote note : punishment.getNotes()) { + String noteText = note.text(); + if (noteText != null && !isAutoGeneratedNote(noteText)) { + reason = noteText; + break; + } + } + } + + Map result = new LinkedHashMap<>(); + result.put("id", punishment.getId()); + result.put("type", typeName); + result.put("category", category); + result.put("typeOrdinal", punishment.getTypeOrdinal()); + result.put("ordinal", punishment.getTypeOrdinal()); + result.put("started", punishment.getStarted() != null); + result.put("expiration", expires != null ? expires.getTime() : null); + result.put("description", reason != null && !reason.isBlank() ? reason : "No reason specified"); + result.put("issuerName", resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers)); + result.put("issuedAt", punishment.getIssued().getTime()); + result.put("playerDescription", playerDescription); + result.put("modifications", punishment.getModifications() + .stream().map(m -> { + Map modMap = new LinkedHashMap<>(); + modMap.put("type", m.type()); + modMap.put("timestamp", m.date() != null ? m.date().getTime() : null); + modMap.put("effectiveDuration", m.effectiveDuration() != null ? m.effectiveDuration() : 0L); + modMap.put("issuerName", resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers)); + return modMap; + }).toList()); + + return result; + } + + public static boolean isAutoGeneratedNote(String noteText) { + if (noteText == null) { + return true; + } + String lower = noteText.toLowerCase(); + return lower.equals("issued punishment") || + lower.startsWith("issued ") || + lower.equals("pardoned punishment") || + lower.equals("added evidence") || + lower.startsWith("changed duration to ") || + lower.startsWith("enabled ") || + lower.startsWith("disabled "); + } + + public static String formatDuration(long durationMs, boolean isPermanent) { + if (isPermanent || durationMs < 0) { + return "Permanent"; + } + if (durationMs == 0) { + return "Instant"; + } + + long totalSeconds = durationMs / 1000; + long days = totalSeconds / 86400; + long hours = (totalSeconds % 86400) / 3600; + long minutes = (totalSeconds % 3600) / 60; + long seconds = totalSeconds % 60; + + StringBuilder sb = new StringBuilder(); + if (days > 0) sb.append(days).append("d"); + if (hours > 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(hours).append("h"); } + if (minutes > 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(minutes).append("m"); } + if (seconds > 0 && days == 0) { if (!sb.isEmpty()) sb.append(" "); sb.append(seconds).append("s"); } + + return sb.isEmpty() ? "0s" : sb.toString(); + } +} diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java b/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java index 0db970c..1cab6b9 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java @@ -1,374 +1,374 @@ -package gg.modl.backend.player.service; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentData; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.data.punishment.PunishmentNote; -import gg.modl.backend.player.dto.request.AddModificationRequest; -import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.AppealWorkflowStatus; -import gg.modl.backend.ticket.service.AppealWorkflowTransitionService; -import gg.modl.backend.ticket.service.TicketService; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import gg.modl.backend.infrastructure.util.IdGenerator; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class PunishmentMutationService { - private final PlayerMongoRepository playerRepository; - private final PunishmentMongoRepository punishmentRepository; - private final TicketService ticketService; - private final AppealWorkflowTransitionService appealWorkflowTransitionService; - private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; - private final PunishmentQueryService punishmentQueryService; - private final PunishmentLifecycleService punishmentLifecycleService; - private final PunishmentRealtimePublisher realtimePublisher; - - public Player addModification(Server server, UUID playerUuid, String punishmentId, AddModificationRequest request) { - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Date now = new Date(); - String modIssuerName = request.issuerId() != null ? null : request.issuerName(); - String modIssuerId = request.issuerId(); - - PunishmentModification modification = new PunishmentModification( - IdGenerator.generateShortId(), - request.type(), - now, - modIssuerName, - modIssuerId, - request.reason() != null ? request.reason() : "", - request.effectiveDuration(), - request.appealTicketId(), - null - ); - - Punishment punishment = findPunishment(player, punishmentId); - if (punishment == null) { - throw new ResourceNotFoundException("Punishment not found"); - } - - syncLinkedAppealOutcome(server, punishmentId, request); - - punishment.getModifications().add(modification); - punishmentRepository.appendModification(server, playerUuid.toString(), punishmentId, modification); - if (request.effectiveDuration() != null && punishment.getStarted() == null) { - punishment.setStarted(now); - punishmentRepository.setPunishmentStartedIfUnset(server, playerUuid.toString(), punishmentId, now); - } - realtimePublisher.punishmentModified(server, player, punishment); - return player; - } - - private void syncLinkedAppealOutcome(Server server, String punishmentId, AddModificationRequest request) { - if (request.appealTicketId() == null) { - return; - } - PunishmentModificationType modificationType = PunishmentModificationType.fromName(request.type()); - AppealWorkflowStatus outcome = modificationType != null ? modificationType.appealOutcome() : null; - if (outcome == null) { - return; - } - String issuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); - appealWorkflowTransitionService.applyOutcomeForPunishment(server, request.appealTicketId(), punishmentId, outcome, issuerName); - } - - private Punishment findPunishment(Player player, String punishmentId) { - return PunishmentQueryService.findPunishment(player, punishmentId); - } - - public PunishmentOperationResult changeDuration(Server server, String punishmentId, Long newDuration, String issuerName, String issuerId) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - String resolvedIssuerName = issuerId != null ? null : issuerName; - - Punishment punishment = context.punishment(); - Date now = new Date(); - - Long effective = (newDuration == null) ? -1L : newDuration; - - PunishmentModification modification = new PunishmentModification( - IdGenerator.generateShortId(), - PunishmentModificationType.MANUAL_DURATION_CHANGE.name(), - now, - resolvedIssuerName, - issuerId, - "Duration changed", - effective, - null, - null - ); - - String durationText = newDuration == null || newDuration < 0 - ? "permanent" - : PunishmentMapper.formatDuration(newDuration, false); - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - "changed duration to " + durationText, - now, - resolvedIssuerName, - issuerId - ); - - punishment.getModifications().add(modification); - punishment.getNotes().add(note); - punishment.getData().put("duration", effective); - String uuid = context.player().getMinecraftUuid().toString(); - punishmentRepository.appendDurationChange(server, uuid, punishmentId, modification, note, effective); - if (punishment.getStarted() == null) { - punishment.setStarted(now); - punishmentRepository.setPunishmentStartedIfUnset(server, uuid, punishmentId, now); - } - realtimePublisher.punishmentModified(server, context.player(), punishment); - - if (PunishmentData.isAltBlocking(punishment.getData())) { - int cascaded = punishmentLifecycleService.cascadeDurationChangeToLinkedBans(server, punishmentId, newDuration, issuerName); - if (cascaded > 0) { - return new PunishmentOperationResult( - PunishmentOperationStatus.SUCCESS, - "Duration changed (cascaded to " + cascaded + " linked ban" + (cascaded > 1 ? "s" : "") + ")", - true, - cascaded + 1 - ); - } - } - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Duration changed", true, 1); - } - - public PunishmentOperationResult toggleOption(Server server, String punishmentId, String option, boolean enabled, String issuerName, String issuerId) { - PunishmentToggleOption toggleOption = PunishmentToggleOption.from(option); - if (toggleOption == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.INVALID_REQUEST, "Invalid option", false, 0); - } - - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - Punishment punishment = context.punishment(); - Date now = new Date(); - String resolvedIssuerName = issuerId != null ? null : issuerName; - PunishmentNote note = new PunishmentNote( - IdGenerator.generateShortId(), - (enabled ? "enabled " : "disabled ") + toggleOption.displayName, - now, - resolvedIssuerName, - issuerId - ); - punishment.getData().put(toggleOption.dataKey, enabled); - punishment.getNotes().add(note); - punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, - Map.of(toggleOption.dataKey, enabled), note); - realtimePublisher.punishmentModified(server, context.player(), punishment); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Option toggled", true, 1); - } - - public PunishmentOperationResult acknowledgeStatWipe(Server server, String punishmentId) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); - } - - Map data = context.punishment().getData(); - if (!PunishmentData.isWipeAfterExpiry(data)) { - return new PunishmentOperationResult( - PunishmentOperationStatus.NO_OP, - "Stat wipe no longer enabled for this punishment", - false, - 0 - ); - } - - punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, - Map.of("statWipeCompleted", true, "statWipeCompletedAt", new Date()), null); - - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Stat wipe acknowledged", true, 1); - } - - public PunishmentOperationResult modifyPunishmentTickets(Server server, String punishmentId, ModifyPunishmentTicketsRequest request) { - PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); - if (context == null) { - return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Failed to modify punishment tickets", false, 0); - } - - applyPunishmentTicketModifications(server, context.player(), context.punishment(), request); - return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Punishment tickets modified", true, 1); - } - - public PunishmentOperationResult modifyPunishmentTickets( - Server server, - String punishmentId, - List addTicketIds, - List removeTicketIds, - boolean modifyAssociatedTickets, - String issuerName, - String issuerId - ) { - return modifyPunishmentTickets( - server, - punishmentId, - new ModifyPunishmentTicketsRequest( - addTicketIds, - removeTicketIds, - modifyAssociatedTickets, - issuerName, - issuerId - ) - ); - } - - public Player modifyPunishmentTickets(Server server, UUID playerUuid, String punishmentId, ModifyPunishmentTicketsRequest request) { - Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) - .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - - Punishment punishment = player.getPunishments() - .stream() - .filter(p -> p.getId().equals(punishmentId)) - .findFirst() - .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); - - applyPunishmentTicketModifications(server, player, punishment, request); - realtimePublisher.punishmentDetailsChanged(server, player, punishment); - return player; - } - - private void applyPunishmentTicketModifications(Server server, Player player, Punishment punishment, ModifyPunishmentTicketsRequest request) { - - List currentIds = punishment.getAttachedTicketIds() != null - ? new ArrayList<>(punishment.getAttachedTicketIds()) - : new ArrayList<>(); - List originalIds = new ArrayList<>(currentIds); - - if (request.addTicketIds() != null) { - for (String id : request.addTicketIds()) { - if (!currentIds.contains(id)) { - currentIds.add(id); - } - } - } - - if (request.removeTicketIds() != null) { - currentIds.removeAll(request.removeTicketIds()); - } - - List addedIds = new ArrayList<>(currentIds); - addedIds.removeAll(originalIds); - - List removedIds = new ArrayList<>(originalIds); - removedIds.removeAll(currentIds); - - punishment.setAttachedTicketIds(currentIds); - punishmentRepository.setPunishmentTickets(server, player.getMinecraftUuid().toString(), punishment.getId(), currentIds); - - if (request.modifyAssociatedTickets()) { - String ticketIssuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); - if (!addedIds.isEmpty()) { - closeAttachedTickets(server, addedIds, ticketIssuerName); - } - if (!removedIds.isEmpty()) { - reopenAttachedTickets(server, removedIds, ticketIssuerName); - } - } - } - - private void closeAttachedTickets(Server server, List ticketIds, String issuerName) { - for (String ticketId : ticketIds) { - try { - ticketService.closeTicketForPunishment(server, ticketId, issuerName); - } catch (Exception e) { - log.error("[TICKET_CLOSE] Failed to close ticket {}", ticketId, e); - } - } - } - - private void reopenAttachedTickets(Server server, List ticketIds, String issuerName) { - for (String ticketId : ticketIds) { - try { - ticketService.reopenTicketForPunishment(server, ticketId, issuerName); - } catch (Exception e) { - log.error("[TICKET_REOPEN] Failed to reopen ticket {}", ticketId, e); - } - } - } - - public void linkAppealToPunishment(Server server, String playerUuid, String punishmentId, - String appealId, PunishmentNote note) { - punishmentRepository.linkAppealToPunishment(server, normalizeUuid(playerUuid), punishmentId, appealId, note); - } - - public void addPunishmentNote(Server server, String playerUuid, String punishmentId, - PunishmentNote note, Map dataUpdates) { - punishmentRepository.addPunishmentNote(server, normalizeUuid(playerUuid), punishmentId, note, dataUpdates); - } - - public void applyAppealApproval(Server server, String playerUuid, String punishmentId, - PunishmentModification modification, PunishmentNote note, - String appealOutcome, String appealTicketId) { - String normalizedUuid = normalizeUuid(playerUuid); - punishmentRepository.applyAppealApproval(server, normalizedUuid, punishmentId, - modification, note, appealOutcome, appealTicketId); - - playerRepository.findByMinecraftUuid(server, normalizedUuid).ifPresent(player -> { - Punishment punishment = findPunishment(player, punishmentId); - if (punishment != null) { - realtimePublisher.punishmentModified(server, player, punishment); - } - }); - } - - private enum PunishmentToggleOption { - ALT_BLOCKING("altBlocking", "alt-blocking"), - STAT_WIPE("wipeAfterExpiry", "stat wipe"); - - private final String dataKey; - private final String displayName; - - PunishmentToggleOption(String dataKey, String displayName) { - this.dataKey = dataKey; - this.displayName = displayName; - } - - private static PunishmentToggleOption from(String option) { - if (option == null || option.isBlank()) { - return null; - } - - try { - return PunishmentToggleOption.valueOf(option.trim().toUpperCase(Locale.ROOT)); - } catch (IllegalArgumentException exception) { - return null; - } - } - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } -} +package gg.modl.backend.player.service; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.player.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.dto.request.AddModificationRequest; +import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.AppealWorkflowStatus; +import gg.modl.backend.ticket.service.AppealWorkflowTransitionService; +import gg.modl.backend.ticket.service.TicketService; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import gg.modl.backend.infrastructure.util.IdGenerator; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PunishmentMutationService { + private final PlayerMongoRepository playerRepository; + private final PunishmentMongoRepository punishmentRepository; + private final TicketService ticketService; + private final AppealWorkflowTransitionService appealWorkflowTransitionService; + private final IssuerNameResolver issuerNameResolver; + private final StaffMongoRepository staffRepository; + private final PunishmentQueryService punishmentQueryService; + private final PunishmentLifecycleService punishmentLifecycleService; + private final PunishmentRealtimePublisher realtimePublisher; + + public Player addModification(Server server, UUID playerUuid, String punishmentId, AddModificationRequest request) { + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Date now = new Date(); + String modIssuerName = request.issuerId() != null ? null : request.issuerName(); + String modIssuerId = request.issuerId(); + + PunishmentModification modification = new PunishmentModification( + IdGenerator.generateShortId(), + request.type(), + now, + modIssuerName, + modIssuerId, + request.reason() != null ? request.reason() : "", + request.effectiveDuration(), + request.appealTicketId(), + null + ); + + Punishment punishment = findPunishment(player, punishmentId); + if (punishment == null) { + throw new ResourceNotFoundException("Punishment not found"); + } + + syncLinkedAppealOutcome(server, punishmentId, request); + + punishment.getModifications().add(modification); + punishmentRepository.appendModification(server, playerUuid.toString(), punishmentId, modification); + if (request.effectiveDuration() != null && punishment.getStarted() == null) { + punishment.setStarted(now); + punishmentRepository.setPunishmentStartedIfUnset(server, playerUuid.toString(), punishmentId, now); + } + realtimePublisher.punishmentModified(server, player, punishment); + return player; + } + + private void syncLinkedAppealOutcome(Server server, String punishmentId, AddModificationRequest request) { + if (request.appealTicketId() == null) { + return; + } + PunishmentModificationType modificationType = PunishmentModificationType.fromName(request.type()); + AppealWorkflowStatus outcome = modificationType != null ? modificationType.appealOutcome() : null; + if (outcome == null) { + return; + } + String issuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); + appealWorkflowTransitionService.applyOutcomeForPunishment(server, request.appealTicketId(), punishmentId, outcome, issuerName); + } + + private Punishment findPunishment(Player player, String punishmentId) { + return PunishmentQueryService.findPunishment(player, punishmentId); + } + + public PunishmentOperationResult changeDuration(Server server, String punishmentId, Long newDuration, String issuerName, String issuerId) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + String resolvedIssuerName = issuerId != null ? null : issuerName; + + Punishment punishment = context.punishment(); + Date now = new Date(); + + Long effective = (newDuration == null) ? -1L : newDuration; + + PunishmentModification modification = new PunishmentModification( + IdGenerator.generateShortId(), + PunishmentModificationType.MANUAL_DURATION_CHANGE.name(), + now, + resolvedIssuerName, + issuerId, + "Duration changed", + effective, + null, + null + ); + + String durationText = newDuration == null || newDuration < 0 + ? "permanent" + : PunishmentMapper.formatDuration(newDuration, false); + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + "changed duration to " + durationText, + now, + resolvedIssuerName, + issuerId + ); + + punishment.getModifications().add(modification); + punishment.getNotes().add(note); + punishment.getData().put("duration", effective); + String uuid = context.player().getMinecraftUuid().toString(); + punishmentRepository.appendDurationChange(server, uuid, punishmentId, modification, note, effective); + if (punishment.getStarted() == null) { + punishment.setStarted(now); + punishmentRepository.setPunishmentStartedIfUnset(server, uuid, punishmentId, now); + } + realtimePublisher.punishmentModified(server, context.player(), punishment); + + if (PunishmentData.isAltBlocking(punishment.getData())) { + int cascaded = punishmentLifecycleService.cascadeDurationChangeToLinkedBans(server, punishmentId, newDuration, issuerName); + if (cascaded > 0) { + return new PunishmentOperationResult( + PunishmentOperationStatus.SUCCESS, + "Duration changed (cascaded to " + cascaded + " linked ban" + (cascaded > 1 ? "s" : "") + ")", + true, + cascaded + 1 + ); + } + } + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Duration changed", true, 1); + } + + public PunishmentOperationResult toggleOption(Server server, String punishmentId, String option, boolean enabled, String issuerName, String issuerId) { + PunishmentToggleOption toggleOption = PunishmentToggleOption.from(option); + if (toggleOption == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.INVALID_REQUEST, "Invalid option", false, 0); + } + + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + Punishment punishment = context.punishment(); + Date now = new Date(); + String resolvedIssuerName = issuerId != null ? null : issuerName; + PunishmentNote note = new PunishmentNote( + IdGenerator.generateShortId(), + (enabled ? "enabled " : "disabled ") + toggleOption.displayName, + now, + resolvedIssuerName, + issuerId + ); + punishment.getData().put(toggleOption.dataKey, enabled); + punishment.getNotes().add(note); + punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, + Map.of(toggleOption.dataKey, enabled), note); + realtimePublisher.punishmentModified(server, context.player(), punishment); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Option toggled", true, 1); + } + + public PunishmentOperationResult acknowledgeStatWipe(Server server, String punishmentId) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); + } + + Map data = context.punishment().getData(); + if (!PunishmentData.isWipeAfterExpiry(data)) { + return new PunishmentOperationResult( + PunishmentOperationStatus.NO_OP, + "Stat wipe no longer enabled for this punishment", + false, + 0 + ); + } + + punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, + Map.of("statWipeCompleted", true, "statWipeCompletedAt", new Date()), null); + + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Stat wipe acknowledged", true, 1); + } + + public PunishmentOperationResult modifyPunishmentTickets(Server server, String punishmentId, ModifyPunishmentTicketsRequest request) { + PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); + if (context == null) { + return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Failed to modify punishment tickets", false, 0); + } + + applyPunishmentTicketModifications(server, context.player(), context.punishment(), request); + return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Punishment tickets modified", true, 1); + } + + public PunishmentOperationResult modifyPunishmentTickets( + Server server, + String punishmentId, + List addTicketIds, + List removeTicketIds, + boolean modifyAssociatedTickets, + String issuerName, + String issuerId + ) { + return modifyPunishmentTickets( + server, + punishmentId, + new ModifyPunishmentTicketsRequest( + addTicketIds, + removeTicketIds, + modifyAssociatedTickets, + issuerName, + issuerId + ) + ); + } + + public Player modifyPunishmentTickets(Server server, UUID playerUuid, String punishmentId, ModifyPunishmentTicketsRequest request) { + Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) + .orElseThrow(() -> new ResourceNotFoundException("Player not found")); + + Punishment punishment = player.getPunishments() + .stream() + .filter(p -> p.getId().equals(punishmentId)) + .findFirst() + .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); + + applyPunishmentTicketModifications(server, player, punishment, request); + realtimePublisher.punishmentDetailsChanged(server, player, punishment); + return player; + } + + private void applyPunishmentTicketModifications(Server server, Player player, Punishment punishment, ModifyPunishmentTicketsRequest request) { + + List currentIds = punishment.getAttachedTicketIds() != null + ? new ArrayList<>(punishment.getAttachedTicketIds()) + : new ArrayList<>(); + List originalIds = new ArrayList<>(currentIds); + + if (request.addTicketIds() != null) { + for (String id : request.addTicketIds()) { + if (!currentIds.contains(id)) { + currentIds.add(id); + } + } + } + + if (request.removeTicketIds() != null) { + currentIds.removeAll(request.removeTicketIds()); + } + + List addedIds = new ArrayList<>(currentIds); + addedIds.removeAll(originalIds); + + List removedIds = new ArrayList<>(originalIds); + removedIds.removeAll(currentIds); + + punishment.setAttachedTicketIds(currentIds); + punishmentRepository.setPunishmentTickets(server, player.getMinecraftUuid().toString(), punishment.getId(), currentIds); + + if (request.modifyAssociatedTickets()) { + String ticketIssuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); + if (!addedIds.isEmpty()) { + closeAttachedTickets(server, addedIds, ticketIssuerName); + } + if (!removedIds.isEmpty()) { + reopenAttachedTickets(server, removedIds, ticketIssuerName); + } + } + } + + private void closeAttachedTickets(Server server, List ticketIds, String issuerName) { + for (String ticketId : ticketIds) { + try { + ticketService.closeTicketForPunishment(server, ticketId, issuerName); + } catch (Exception e) { + log.error("[TICKET_CLOSE] Failed to close ticket {}", ticketId, e); + } + } + } + + private void reopenAttachedTickets(Server server, List ticketIds, String issuerName) { + for (String ticketId : ticketIds) { + try { + ticketService.reopenTicketForPunishment(server, ticketId, issuerName); + } catch (Exception e) { + log.error("[TICKET_REOPEN] Failed to reopen ticket {}", ticketId, e); + } + } + } + + public void linkAppealToPunishment(Server server, String playerUuid, String punishmentId, + String appealId, PunishmentNote note) { + punishmentRepository.linkAppealToPunishment(server, normalizeUuid(playerUuid), punishmentId, appealId, note); + } + + public void addPunishmentNote(Server server, String playerUuid, String punishmentId, + PunishmentNote note, Map dataUpdates) { + punishmentRepository.addPunishmentNote(server, normalizeUuid(playerUuid), punishmentId, note, dataUpdates); + } + + public void applyAppealApproval(Server server, String playerUuid, String punishmentId, + PunishmentModification modification, PunishmentNote note, + String appealOutcome, String appealTicketId) { + String normalizedUuid = normalizeUuid(playerUuid); + punishmentRepository.applyAppealApproval(server, normalizedUuid, punishmentId, + modification, note, appealOutcome, appealTicketId); + + playerRepository.findByMinecraftUuid(server, normalizedUuid).ifPresent(player -> { + Punishment punishment = findPunishment(player, punishmentId); + if (punishment != null) { + realtimePublisher.punishmentModified(server, player, punishment); + } + }); + } + + private enum PunishmentToggleOption { + ALT_BLOCKING("altBlocking", "alt-blocking"), + STAT_WIPE("wipeAfterExpiry", "stat wipe"); + + private final String dataKey; + private final String displayName; + + PunishmentToggleOption(String dataKey, String displayName) { + this.dataKey = dataKey; + this.displayName = displayName; + } + + private static PunishmentToggleOption from(String option) { + if (option == null || option.isBlank()) { + return null; + } + + try { + return PunishmentToggleOption.valueOf(option.trim().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + return null; + } + } + } + + private static String normalizeUuid(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java index 17f6dc8..0b72e20 100644 --- a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java +++ b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java @@ -1,38 +1,38 @@ -package gg.modl.backend.punishment.controller; - -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -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(RESTMappingV1.PUBLIC_PUNISHMENT) -@RequiredArgsConstructor -public class PublicPunishmentController { - private final PunishmentQueryService punishmentQueryService; - - @GetMapping("/{punishmentId}/appeal-info") - public ResponseEntity getAppealInfo( - @PathVariable String punishmentId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Map result = punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, punishmentId) - .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); - if (result.containsKey("error")) { - return ResponseEntity.badRequest().body(result); - } - - return ResponseEntity.ok(PublicPunishmentProtoMapper.toAppealInfo(result)); - } -} +package gg.modl.backend.punishment.controller; + +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +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(RESTMappingV1.PUBLIC_PUNISHMENT) +@RequiredArgsConstructor +public class PublicPunishmentController { + private final PunishmentQueryService punishmentQueryService; + + @GetMapping("/{punishmentId}/appeal-info") + public ResponseEntity getAppealInfo( + @PathVariable String punishmentId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Map result = punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, punishmentId) + .orElseThrow(() -> new ResourceNotFoundException("Punishment not found")); + if (result.containsKey("error")) { + return ResponseEntity.badRequest().body(result); + } + + return ResponseEntity.ok(PublicPunishmentProtoMapper.toAppealInfo(result)); + } +} diff --git a/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java b/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java index c95599e..e274aa7 100644 --- a/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java +++ b/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java @@ -1,161 +1,161 @@ -package gg.modl.backend.role.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.log.service.PanelActionAuditor; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.data.Permission; -import gg.modl.backend.role.dto.request.ReorderRolesRequest; -import gg.modl.backend.role.dto.request.RoleRequest; -import gg.modl.backend.role.dto.response.RoleResponse; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.role.service.RoleService; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PanelRoleListResponse; -import gg.modl.proto.modl.v1.PermissionsResponse; -import gg.modl.proto.modl.v1.RoleDetailResponse; -import gg.modl.proto.modl.v1.RoleMutationResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_ROLES) -@RequiredArgsConstructor -public class PanelRoleController { - private final RoleService roleService; - private final PermissionService permissionService; - private final RoleAuthorization roleAuthorization; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PanelActionAuditor panelActionAuditor; - private final Validator validator; - - @GetMapping - public ResponseEntity getAllRoles(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List roles = roleService.getAllRoles(server); - return ResponseEntity.ok(PanelRoleProtoMapper.toRoleListResponse(roles)); - } - - @GetMapping("/permissions") - public ResponseEntity getPermissions(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List permissions = permissionService.getAllPermissions(server); - Map categories = permissionService.getPermissionCategories(); - return ResponseEntity.ok(PanelRoleProtoMapper.toPermissionsResponse(permissions, categories)); - } - - @GetMapping("/{id}") - public ResponseEntity getRoleById( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - return roleService.getRoleById(server, id) - .map(PanelRoleProtoMapper::toRoleDetailResponse) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - @PostMapping - public ResponseEntity createRole( - @RequestBody gg.modl.proto.modl.v1.RoleRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String performerEmail = RequestUtil.getSessionEmail(request); - RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, performerEmail); - - RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(createRequest); - validate(mappedRequest); - RoleResponse role = roleService.createRole(server, mappedRequest, performer); - invalidateRoles(server, role.id()); - panelActionAuditor.recordStaffAction(server, performerEmail, "Created staff role: " + role.name()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(PanelRoleProtoMapper.toRoleMutationResponse("Role created successfully", role)); - } - - @PutMapping("/{id}") - public ResponseEntity updateRole( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.RoleRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(updateRequest); - validate(mappedRequest); - return roleService.updateRole(server, id, mappedRequest, performer) - .map(role -> { - invalidateRoles(server, role.id()); - return ResponseEntity.ok( - PanelRoleProtoMapper.toRoleMutationResponse("Role updated successfully", role)); - }) - .orElse(ResponseEntity.notFound().build()); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteRole( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - boolean deleted = roleService.deleteRole(server, id, performer); - if (deleted) { - invalidateRoles(server, id); - return ResponseEntity.ok( - PanelRoleProtoMapper.toRoleMutationResponse("Role deleted successfully", null)); - } - return ResponseEntity.notFound().build(); - } - - @PostMapping("/reorder") - public ResponseEntity reorderRoles( - @RequestBody gg.modl.proto.modl.v1.ReorderRolesRequest reorderRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - ReorderRolesRequest mappedRequest = PanelRoleProtoMapper.toReorderRolesRequest(reorderRequest); - roleService.reorderRoles(server, mappedRequest, performer); - invalidateRoles(server, null); - return ResponseEntity.ok( - PanelRoleProtoMapper.toRoleMutationResponse("Role order updated successfully", null)); - } - - private void invalidateRoles(Server server, String roleId) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_ROLES, roleId); - } - - private void validate(T request) { - Set> violations = validator.validate(request); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } - } -} +package gg.modl.backend.role.controller; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.log.service.PanelActionAuditor; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.data.Permission; +import gg.modl.backend.role.dto.request.ReorderRolesRequest; +import gg.modl.backend.role.dto.request.RoleRequest; +import gg.modl.backend.role.dto.response.RoleResponse; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.role.service.RoleService; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PanelRoleListResponse; +import gg.modl.proto.modl.v1.PermissionsResponse; +import gg.modl.proto.modl.v1.RoleDetailResponse; +import gg.modl.proto.modl.v1.RoleMutationResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_ROLES) +@RequiredArgsConstructor +public class PanelRoleController { + private final RoleService roleService; + private final PermissionService permissionService; + private final RoleAuthorization roleAuthorization; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PanelActionAuditor panelActionAuditor; + private final Validator validator; + + @GetMapping + public ResponseEntity getAllRoles(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List roles = roleService.getAllRoles(server); + return ResponseEntity.ok(PanelRoleProtoMapper.toRoleListResponse(roles)); + } + + @GetMapping("/permissions") + public ResponseEntity getPermissions(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List permissions = permissionService.getAllPermissions(server); + Map categories = permissionService.getPermissionCategories(); + return ResponseEntity.ok(PanelRoleProtoMapper.toPermissionsResponse(permissions, categories)); + } + + @GetMapping("/{id}") + public ResponseEntity getRoleById( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + return roleService.getRoleById(server, id) + .map(PanelRoleProtoMapper::toRoleDetailResponse) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createRole( + @RequestBody gg.modl.proto.modl.v1.RoleRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String performerEmail = RequestUtil.getSessionEmail(request); + RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, performerEmail); + + RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(createRequest); + validate(mappedRequest); + RoleResponse role = roleService.createRole(server, mappedRequest, performer); + invalidateRoles(server, role.id()); + panelActionAuditor.recordStaffAction(server, performerEmail, "Created staff role: " + role.name()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(PanelRoleProtoMapper.toRoleMutationResponse("Role created successfully", role)); + } + + @PutMapping("/{id}") + public ResponseEntity updateRole( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.RoleRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(updateRequest); + validate(mappedRequest); + return roleService.updateRole(server, id, mappedRequest, performer) + .map(role -> { + invalidateRoles(server, role.id()); + return ResponseEntity.ok( + PanelRoleProtoMapper.toRoleMutationResponse("Role updated successfully", role)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteRole( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + boolean deleted = roleService.deleteRole(server, id, performer); + if (deleted) { + invalidateRoles(server, id); + return ResponseEntity.ok( + PanelRoleProtoMapper.toRoleMutationResponse("Role deleted successfully", null)); + } + return ResponseEntity.notFound().build(); + } + + @PostMapping("/reorder") + public ResponseEntity reorderRoles( + @RequestBody gg.modl.proto.modl.v1.ReorderRolesRequest reorderRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + ReorderRolesRequest mappedRequest = PanelRoleProtoMapper.toReorderRolesRequest(reorderRequest); + roleService.reorderRoles(server, mappedRequest, performer); + invalidateRoles(server, null); + return ResponseEntity.ok( + PanelRoleProtoMapper.toRoleMutationResponse("Role order updated successfully", null)); + } + + private void invalidateRoles(Server server, String roleId) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_ROLES, roleId); + } + + private void validate(T request) { + Set> violations = validator.validate(request); + if (!violations.isEmpty()) { + throw new ValidationException(violations.iterator().next().getMessage()); + } + } +} diff --git a/src/main/java/gg/modl/backend/role/data/Permission.java b/src/main/java/gg/modl/backend/role/data/Permission.java index f588c7b..3e9ee0f 100644 --- a/src/main/java/gg/modl/backend/role/data/Permission.java +++ b/src/main/java/gg/modl/backend/role/data/Permission.java @@ -1,13 +1,13 @@ -package gg.modl.backend.role.data; - -public record Permission( - String id, - String name, - String description, - String category, - String parentId -) { - public Permission(String id, String name, String description, String category) { - this(id, name, description, category, null); - } -} +package gg.modl.backend.role.data; + +public record Permission( + String id, + String name, + String description, + String category, + String parentId +) { + public Permission(String id, String name, String description, String category) { + this(id, name, description, category, null); + } +} diff --git a/src/main/java/gg/modl/backend/role/data/StaffRole.java b/src/main/java/gg/modl/backend/role/data/StaffRole.java index 7dd53ec..737a815 100644 --- a/src/main/java/gg/modl/backend/role/data/StaffRole.java +++ b/src/main/java/gg/modl/backend/role/data/StaffRole.java @@ -1,48 +1,48 @@ -package gg.modl.backend.role.data; - -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@GenerateMongoFields -public class StaffRole { - @Id - private String id; - - @Field("name") - private String name; - - @Field("description") - private String description; - - @Field("permissions") - @Builder.Default - private List permissions = new ArrayList<>(); - - @Field("isDefault") - @Builder.Default - private boolean isDefault = false; - - @Field("order") - @Builder.Default - private int order = 999; - - @Field("createdAt") - private Date createdAt; - - @Field("updatedAt") - private Date updatedAt; -} +package gg.modl.backend.role.data; + +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@GenerateMongoFields +public class StaffRole { + @Id + private String id; + + @Field("name") + private String name; + + @Field("description") + private String description; + + @Field("permissions") + @Builder.Default + private List permissions = new ArrayList<>(); + + @Field("isDefault") + @Builder.Default + private boolean isDefault = false; + + @Field("order") + @Builder.Default + private int order = 999; + + @Field("createdAt") + private Date createdAt; + + @Field("updatedAt") + private Date updatedAt; +} diff --git a/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java b/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java index 64b6836..df6cbdb 100644 --- a/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java +++ b/src/main/java/gg/modl/backend/role/dto/response/RoleResponse.java @@ -1,17 +1,17 @@ -package gg.modl.backend.role.dto.response; - -import java.util.Date; -import java.util.List; - -public record RoleResponse( - String id, - String name, - String description, - List permissions, - boolean isDefault, - int order, - int userCount, - Date createdAt, - Date updatedAt -) { -} +package gg.modl.backend.role.dto.response; + +import java.util.Date; +import java.util.List; + +public record RoleResponse( + String id, + String name, + String description, + List permissions, + boolean isDefault, + int order, + int userCount, + Date createdAt, + Date updatedAt +) { +} diff --git a/src/main/java/gg/modl/backend/role/service/PermissionService.java b/src/main/java/gg/modl/backend/role/service/PermissionService.java index c85947f..b4e40cb 100644 --- a/src/main/java/gg/modl/backend/role/service/PermissionService.java +++ b/src/main/java/gg/modl/backend/role/service/PermissionService.java @@ -1,272 +1,272 @@ -package gg.modl.backend.role.service; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; -import gg.modl.backend.role.data.Permission; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Comparator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class PermissionService { - private final StaffRoleMongoRepository staffRoleRepository; - private final PunishmentTypeService punishmentTypeService; - private final StaffMongoRepository staffRepository; - - private final Cache permissionCache = Caffeine.newBuilder() - .maximumSize(2000) - .expireAfterWrite(Duration.ofMinutes(2)) - .build(); - - private static final List BASE_PERMISSIONS = List.of( - // Admin permissions - new Permission("admin.settings.view", "View Settings", "View all system settings (includes all sub-permissions)", "admin"), - new Permission("admin.settings.view.punishments", "View Punishments Config", "View punishment type configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.view.content", "View Content", "View homepage cards, knowledgebase, media", "admin", "admin.settings.view"), - new Permission("admin.settings.view.domain", "View Domain", "View custom domain configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.view.billing", "View Billing", "View billing, subscription, and payment info", "admin", "admin.settings.view"), - new Permission("admin.settings.view.migration", "View Migration", "View import/export data configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.view.storage", "View Storage", "View storage configuration", "admin", "admin.settings.view"), - new Permission("admin.settings.modify", "Modify Settings", "Full control over system settings (includes all sub-permissions)", "admin"), - new Permission("admin.settings.modify.punishments", "Modify Punishments Config", "Create/edit/delete punishment types", "admin", - "admin.settings.modify"), - new Permission("admin.settings.modify.content", "Modify Content", "Edit homepage cards, knowledgebase, media", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.domain", "Modify Domain", "Change custom domain configuration", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.billing", "Modify Billing", "Update subscription and payment methods", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.migration", "Modify Migration", "Import/export data between platforms", "admin", "admin.settings.modify"), - new Permission("admin.settings.modify.storage", "Modify Storage", "Configure storage backends and limits", "admin", "admin.settings.modify"), - new Permission("admin.staff.manage", "Manage Staff", "Full staff management (includes all sub-permissions)", "admin"), - new Permission("admin.staff.manage.members", "Manage Members", "Invite, remove, and reassign staff", "admin", "admin.staff.manage"), - new Permission("admin.staff.manage.roles", "Manage Roles", "Create/edit/delete roles and permissions", "admin", "admin.staff.manage"), - new Permission("admin.audit.view", "View Audit", "Full audit access (includes all sub-permissions)", "admin"), - new Permission("admin.audit.view.dashboard", "View Dashboard", "View dashboard statistics", "admin", "admin.audit.view"), - new Permission("admin.audit.view.analytics", "View Analytics", "View player and ticket analytics", "admin", "admin.audit.view"), - new Permission("admin.audit.view.logs", "View Logs", "View audit trail of staff actions", "admin", "admin.audit.view"), - - // Punishment permissions - new Permission("punishment.view", "View Punishments", "View player profiles, punishments, and linked accounts", "punishment"), - new Permission("punishment.modify", "Modify Punishments", "Full control over existing punishments (includes all sub-permissions)", "punishment"), - new Permission("punishment.modify.pardon", "Pardon Punishments", "Pardon punishments and clear associated points", "punishment", "punishment.modify"), - new Permission("punishment.modify.duration", "Modify Duration", "Change punishment duration", "punishment", "punishment.modify"), - new Permission("punishment.modify.note", "Add Notes", "Add staff notes to punishments", "punishment", "punishment.modify"), - new Permission("punishment.modify.evidence", "Manage Evidence", "Add and view evidence on punishments", "punishment", "punishment.modify"), - new Permission("punishment.modify.options", "Toggle Options", "Toggle alt-blocking and stat-wipe options", "punishment", "punishment.modify"), - - // Staff tool permissions - new Permission("staff.chat.toggle", "Toggle Chat", "Toggle server chat on/off", "staff"), - new Permission("staff.chat.clear", "Clear Chat", "Clear server chat", "staff"), - new Permission("staff.chat.slow", "Slow Chat", "Set slow mode on server chat", "staff"), - new Permission("staff.maintenance", "Maintenance Mode", "Toggle server maintenance mode", "staff"), - new Permission("staff.modactions", "Moderation Actions", "Staff mode, vanish, freeze, and target players", "staff"), - new Permission("staff.intercept", "Intercept Chat", "Intercept and view all network chat", "staff"), - new Permission("staff.chatlogs", "Chat Logs", "View player chat history", "staff"), - new Permission("staff.commandlogs", "Command Logs", "View player command history", "staff"), - - // Ticket permissions - new Permission("ticket.view.all", "View All Tickets", "View all tickets (includes all sub-permissions)", "ticket"), - new Permission("ticket.view.all.notes", "View Staff Notes", "View internal staff notes on tickets", "ticket", "ticket.view.all"), - new Permission("ticket.reply.all", "Reply to All Tickets", "Reply to all ticket types (includes all sub-permissions)", "ticket"), - new Permission("ticket.reply.all.notes", "Add Staff Notes", "Add staff-only internal notes", "ticket", "ticket.reply.all"), - new Permission("appeal.modify", "Modify Appeals", "Reply to and update appeals", "ticket"), - new Permission("ticket.close.all", "Close/Reopen All Tickets", "Close and reopen all ticket types (includes all sub-permissions)", "ticket"), - new Permission("ticket.close.all.lock", "Lock Tickets", "Lock tickets to prevent further replies", "ticket", "ticket.close.all"), - new Permission("ticket.manage", "Manage Tickets", "Advanced ticket management (includes all sub-permissions)", "ticket"), - new Permission("ticket.manage.tags", "Manage Tags", "Add and remove tags from tickets", "ticket", "ticket.manage"), - new Permission("ticket.manage.hide", "Hide Tickets", "Hide tickets from public view", "ticket", "ticket.manage"), - new Permission("ticket.manage.subscribe", "Manage Subscriptions", "Manage ticket notification subscriptions", "ticket", "ticket.manage"), - new Permission("ticket.delete.all", "Delete Tickets", "Delete tickets from the system", "ticket") - ); - - private static final Map PERMISSION_CATEGORIES = Map.of( - "punishment", "Punishment Permissions", - "ticket", "Ticket Permissions", - "admin", "Administrative Permissions", - "staff", "Staff Tool Permissions" - ); - - public List getBasePermissions() { - return BASE_PERMISSIONS; - } - - public Map getPermissionCategories() { - return PERMISSION_CATEGORIES; - } - - public List getAllPermissionIds(Server server) { - return getAllPermissions(server).stream().map(Permission::id).toList(); - } - - public List getAllPermissions(Server server) { - List all = new ArrayList<>(BASE_PERMISSIONS); - all.addAll(getPunishmentPermissions(server)); - return all; - } - - public List getPunishmentPermissions(Server server) { - List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - List permissions = new ArrayList<>(); - - punishmentTypes.forEach(type -> { - String permId = punishmentApplyPermissionId(type.getName()); - permissions.add(new Permission( - permId, - "Apply " + type.getName(), - "Permission to apply " + type.getName() + " punishments", - "punishment" - )); - }); - - return permissions; - } - - public static String punishmentApplyPermissionId(String typeName) { - return "punishment.apply." + typeName.toLowerCase().replace(" ", "-"); - } - - public void renamePunishmentApplyPermission(Server server, String oldName, String newName) { - if (oldName == null || newName == null) { - return; - } - String oldId = punishmentApplyPermissionId(oldName); - String newId = punishmentApplyPermissionId(newName); - if (oldId.equals(newId)) { - return; - } - - boolean changed = false; - for (StaffRole role : staffRoleRepository.findAllOrdered(server)) { - List permissions = role.getPermissions(); - if (permissions == null || !permissions.contains(oldId)) { - continue; - } - List migrated = new ArrayList<>(); - for (String permission : permissions) { - String replacement = permission.equals(oldId) ? newId : permission; - if (!migrated.contains(replacement)) { - migrated.add(replacement); - } - } - role.setPermissions(migrated); - staffRoleRepository.upsertRole(server, role); - changed = true; - } - - if (changed) { - permissionCache.invalidateAll(); - } - } - - public boolean hasPermission(Server server, String roleId, String permission) { - if (roleId == null || roleId.isBlank()) { - return false; - } - - String trimmedRole = roleId.trim(); - String cacheKey = server.getId() + ":" + trimmedRole + ":" + permission; - return permissionCache.get(cacheKey, key -> computeHasPermission(server, trimmedRole, permission)); - } - - private boolean computeHasPermission(Server server, String roleId, String permission) { - StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); - if (role == null) { - return false; - } - return RoleAuthorization.roleGrants(role, permission); - } - - public boolean hasAnyPermissionWithPrefix(Server server, String roleId, String prefix) { - if (roleId == null || roleId.isBlank()) { - return false; - } - - String trimmedRole = roleId.trim(); - String cacheKey = server.getId() + ":" + trimmedRole + ":prefix:" + prefix; - return permissionCache.get(cacheKey, key -> computeHasPermissionWithPrefix(server, trimmedRole, prefix)); - } - - private boolean computeHasPermissionWithPrefix(Server server, String roleId, String prefix) { - StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); - if (role == null) { - return false; - } - if (RoleAuthorization.isSuperAdminRole(role)) { - return true; - } - return role.getPermissions().stream().anyMatch(p -> p.startsWith(prefix)); - } - - public void evictPermissionCache() { - permissionCache.invalidateAll(); - } - - public Optional getRoleByName(Server server, String roleName) { - if (roleName == null || roleName.isBlank()) { - return Optional.empty(); - } - - return staffRoleRepository.findAllByName(server, roleName.trim()).stream() - .min(Comparator.comparingInt(StaffRole::getOrder) - .thenComparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) - .thenComparing(StaffRole::getId)); - } - - public Optional getRoleById(Server server, String roleId) { - if (roleId == null || roleId.isBlank()) { - return Optional.empty(); - } - - return staffRoleRepository.findById(server, roleId.trim()); - } - - public Map getRolesByIds(Server server, Collection roleIds) { - Set ids = roleIds.stream() - .filter(id -> id != null && !id.isBlank()) - .collect(Collectors.toSet()); - if (ids.isEmpty()) { - return Map.of(); - } - return staffRoleRepository.findByIds(server, ids).stream() - .collect(Collectors.toMap(StaffRole::getId, Function.identity(), (left, right) -> left, LinkedHashMap::new)); - } - - public Map resolveRoleNames(Server server, Collection roleIds) { - Map names = new LinkedHashMap<>(); - getRolesByIds(server, roleIds).forEach((id, role) -> names.put(id, role.getName())); - return names; - } - - public String resolveRoleName(Server server, String roleId) { - if (roleId == null || roleId.isBlank()) { - return ""; - } - return getRoleById(server, roleId).map(StaffRole::getName).orElse(roleId); - } - - public boolean isSuperAdmin(Server server, String staffEmail) { - return RoleAuthorization.isSuperAdminEmail(server, staffEmail); - } - - public boolean isAuthorizedEmail(Server server, String email) { - if (isSuperAdmin(server, email)) { - return true; - } - return staffRepository.findByEmailIgnoreCase(server, email).isPresent(); - } -} +package gg.modl.backend.role.service; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; +import gg.modl.backend.role.data.Permission; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PermissionService { + private final StaffRoleMongoRepository staffRoleRepository; + private final PunishmentTypeService punishmentTypeService; + private final StaffMongoRepository staffRepository; + + private final Cache permissionCache = Caffeine.newBuilder() + .maximumSize(2000) + .expireAfterWrite(Duration.ofMinutes(2)) + .build(); + + private static final List BASE_PERMISSIONS = List.of( + // Admin permissions + new Permission("admin.settings.view", "View Settings", "View all system settings (includes all sub-permissions)", "admin"), + new Permission("admin.settings.view.punishments", "View Punishments Config", "View punishment type configuration", "admin", "admin.settings.view"), + new Permission("admin.settings.view.content", "View Content", "View homepage cards, knowledgebase, media", "admin", "admin.settings.view"), + new Permission("admin.settings.view.domain", "View Domain", "View custom domain configuration", "admin", "admin.settings.view"), + new Permission("admin.settings.view.billing", "View Billing", "View billing, subscription, and payment info", "admin", "admin.settings.view"), + new Permission("admin.settings.view.migration", "View Migration", "View import/export data configuration", "admin", "admin.settings.view"), + new Permission("admin.settings.view.storage", "View Storage", "View storage configuration", "admin", "admin.settings.view"), + new Permission("admin.settings.modify", "Modify Settings", "Full control over system settings (includes all sub-permissions)", "admin"), + new Permission("admin.settings.modify.punishments", "Modify Punishments Config", "Create/edit/delete punishment types", "admin", + "admin.settings.modify"), + new Permission("admin.settings.modify.content", "Modify Content", "Edit homepage cards, knowledgebase, media", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.domain", "Modify Domain", "Change custom domain configuration", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.billing", "Modify Billing", "Update subscription and payment methods", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.migration", "Modify Migration", "Import/export data between platforms", "admin", "admin.settings.modify"), + new Permission("admin.settings.modify.storage", "Modify Storage", "Configure storage backends and limits", "admin", "admin.settings.modify"), + new Permission("admin.staff.manage", "Manage Staff", "Full staff management (includes all sub-permissions)", "admin"), + new Permission("admin.staff.manage.members", "Manage Members", "Invite, remove, and reassign staff", "admin", "admin.staff.manage"), + new Permission("admin.staff.manage.roles", "Manage Roles", "Create/edit/delete roles and permissions", "admin", "admin.staff.manage"), + new Permission("admin.audit.view", "View Audit", "Full audit access (includes all sub-permissions)", "admin"), + new Permission("admin.audit.view.dashboard", "View Dashboard", "View dashboard statistics", "admin", "admin.audit.view"), + new Permission("admin.audit.view.analytics", "View Analytics", "View player and ticket analytics", "admin", "admin.audit.view"), + new Permission("admin.audit.view.logs", "View Logs", "View audit trail of staff actions", "admin", "admin.audit.view"), + + // Punishment permissions + new Permission("punishment.view", "View Punishments", "View player profiles, punishments, and linked accounts", "punishment"), + new Permission("punishment.modify", "Modify Punishments", "Full control over existing punishments (includes all sub-permissions)", "punishment"), + new Permission("punishment.modify.pardon", "Pardon Punishments", "Pardon punishments and clear associated points", "punishment", "punishment.modify"), + new Permission("punishment.modify.duration", "Modify Duration", "Change punishment duration", "punishment", "punishment.modify"), + new Permission("punishment.modify.note", "Add Notes", "Add staff notes to punishments", "punishment", "punishment.modify"), + new Permission("punishment.modify.evidence", "Manage Evidence", "Add and view evidence on punishments", "punishment", "punishment.modify"), + new Permission("punishment.modify.options", "Toggle Options", "Toggle alt-blocking and stat-wipe options", "punishment", "punishment.modify"), + + // Staff tool permissions + new Permission("staff.chat.toggle", "Toggle Chat", "Toggle server chat on/off", "staff"), + new Permission("staff.chat.clear", "Clear Chat", "Clear server chat", "staff"), + new Permission("staff.chat.slow", "Slow Chat", "Set slow mode on server chat", "staff"), + new Permission("staff.maintenance", "Maintenance Mode", "Toggle server maintenance mode", "staff"), + new Permission("staff.modactions", "Moderation Actions", "Staff mode, vanish, freeze, and target players", "staff"), + new Permission("staff.intercept", "Intercept Chat", "Intercept and view all network chat", "staff"), + new Permission("staff.chatlogs", "Chat Logs", "View player chat history", "staff"), + new Permission("staff.commandlogs", "Command Logs", "View player command history", "staff"), + + // Ticket permissions + new Permission("ticket.view.all", "View All Tickets", "View all tickets (includes all sub-permissions)", "ticket"), + new Permission("ticket.view.all.notes", "View Staff Notes", "View internal staff notes on tickets", "ticket", "ticket.view.all"), + new Permission("ticket.reply.all", "Reply to All Tickets", "Reply to all ticket types (includes all sub-permissions)", "ticket"), + new Permission("ticket.reply.all.notes", "Add Staff Notes", "Add staff-only internal notes", "ticket", "ticket.reply.all"), + new Permission("appeal.modify", "Modify Appeals", "Reply to and update appeals", "ticket"), + new Permission("ticket.close.all", "Close/Reopen All Tickets", "Close and reopen all ticket types (includes all sub-permissions)", "ticket"), + new Permission("ticket.close.all.lock", "Lock Tickets", "Lock tickets to prevent further replies", "ticket", "ticket.close.all"), + new Permission("ticket.manage", "Manage Tickets", "Advanced ticket management (includes all sub-permissions)", "ticket"), + new Permission("ticket.manage.tags", "Manage Tags", "Add and remove tags from tickets", "ticket", "ticket.manage"), + new Permission("ticket.manage.hide", "Hide Tickets", "Hide tickets from public view", "ticket", "ticket.manage"), + new Permission("ticket.manage.subscribe", "Manage Subscriptions", "Manage ticket notification subscriptions", "ticket", "ticket.manage"), + new Permission("ticket.delete.all", "Delete Tickets", "Delete tickets from the system", "ticket") + ); + + private static final Map PERMISSION_CATEGORIES = Map.of( + "punishment", "Punishment Permissions", + "ticket", "Ticket Permissions", + "admin", "Administrative Permissions", + "staff", "Staff Tool Permissions" + ); + + public List getBasePermissions() { + return BASE_PERMISSIONS; + } + + public Map getPermissionCategories() { + return PERMISSION_CATEGORIES; + } + + public List getAllPermissionIds(Server server) { + return getAllPermissions(server).stream().map(Permission::id).toList(); + } + + public List getAllPermissions(Server server) { + List all = new ArrayList<>(BASE_PERMISSIONS); + all.addAll(getPunishmentPermissions(server)); + return all; + } + + public List getPunishmentPermissions(Server server) { + List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); + List permissions = new ArrayList<>(); + + punishmentTypes.forEach(type -> { + String permId = punishmentApplyPermissionId(type.getName()); + permissions.add(new Permission( + permId, + "Apply " + type.getName(), + "Permission to apply " + type.getName() + " punishments", + "punishment" + )); + }); + + return permissions; + } + + public static String punishmentApplyPermissionId(String typeName) { + return "punishment.apply." + typeName.toLowerCase().replace(" ", "-"); + } + + public void renamePunishmentApplyPermission(Server server, String oldName, String newName) { + if (oldName == null || newName == null) { + return; + } + String oldId = punishmentApplyPermissionId(oldName); + String newId = punishmentApplyPermissionId(newName); + if (oldId.equals(newId)) { + return; + } + + boolean changed = false; + for (StaffRole role : staffRoleRepository.findAllOrdered(server)) { + List permissions = role.getPermissions(); + if (permissions == null || !permissions.contains(oldId)) { + continue; + } + List migrated = new ArrayList<>(); + for (String permission : permissions) { + String replacement = permission.equals(oldId) ? newId : permission; + if (!migrated.contains(replacement)) { + migrated.add(replacement); + } + } + role.setPermissions(migrated); + staffRoleRepository.upsertRole(server, role); + changed = true; + } + + if (changed) { + permissionCache.invalidateAll(); + } + } + + public boolean hasPermission(Server server, String roleId, String permission) { + if (roleId == null || roleId.isBlank()) { + return false; + } + + String trimmedRole = roleId.trim(); + String cacheKey = server.getId() + ":" + trimmedRole + ":" + permission; + return permissionCache.get(cacheKey, key -> computeHasPermission(server, trimmedRole, permission)); + } + + private boolean computeHasPermission(Server server, String roleId, String permission) { + StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); + if (role == null) { + return false; + } + return RoleAuthorization.roleGrants(role, permission); + } + + public boolean hasAnyPermissionWithPrefix(Server server, String roleId, String prefix) { + if (roleId == null || roleId.isBlank()) { + return false; + } + + String trimmedRole = roleId.trim(); + String cacheKey = server.getId() + ":" + trimmedRole + ":prefix:" + prefix; + return permissionCache.get(cacheKey, key -> computeHasPermissionWithPrefix(server, trimmedRole, prefix)); + } + + private boolean computeHasPermissionWithPrefix(Server server, String roleId, String prefix) { + StaffRole role = staffRoleRepository.findById(server, roleId).orElse(null); + if (role == null) { + return false; + } + if (RoleAuthorization.isSuperAdminRole(role)) { + return true; + } + return role.getPermissions().stream().anyMatch(p -> p.startsWith(prefix)); + } + + public void evictPermissionCache() { + permissionCache.invalidateAll(); + } + + public Optional getRoleByName(Server server, String roleName) { + if (roleName == null || roleName.isBlank()) { + return Optional.empty(); + } + + return staffRoleRepository.findAllByName(server, roleName.trim()).stream() + .min(Comparator.comparingInt(StaffRole::getOrder) + .thenComparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(StaffRole::getId)); + } + + public Optional getRoleById(Server server, String roleId) { + if (roleId == null || roleId.isBlank()) { + return Optional.empty(); + } + + return staffRoleRepository.findById(server, roleId.trim()); + } + + public Map getRolesByIds(Server server, Collection roleIds) { + Set ids = roleIds.stream() + .filter(id -> id != null && !id.isBlank()) + .collect(Collectors.toSet()); + if (ids.isEmpty()) { + return Map.of(); + } + return staffRoleRepository.findByIds(server, ids).stream() + .collect(Collectors.toMap(StaffRole::getId, Function.identity(), (left, right) -> left, LinkedHashMap::new)); + } + + public Map resolveRoleNames(Server server, Collection roleIds) { + Map names = new LinkedHashMap<>(); + getRolesByIds(server, roleIds).forEach((id, role) -> names.put(id, role.getName())); + return names; + } + + public String resolveRoleName(Server server, String roleId) { + if (roleId == null || roleId.isBlank()) { + return ""; + } + return getRoleById(server, roleId).map(StaffRole::getName).orElse(roleId); + } + + public boolean isSuperAdmin(Server server, String staffEmail) { + return RoleAuthorization.isSuperAdminEmail(server, staffEmail); + } + + public boolean isAuthorizedEmail(Server server, String email) { + if (isSuperAdmin(server, email)) { + return true; + } + return staffRepository.findByEmailIgnoreCase(server, email).isPresent(); + } +} diff --git a/src/main/java/gg/modl/backend/role/service/RoleService.java b/src/main/java/gg/modl/backend/role/service/RoleService.java index eb7e3c3..168f3c4 100644 --- a/src/main/java/gg/modl/backend/role/service/RoleService.java +++ b/src/main/java/gg/modl/backend/role/service/RoleService.java @@ -1,394 +1,394 @@ -package gg.modl.backend.role.service; - -import gg.modl.backend.infrastructure.exception.ConflictException; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; -import gg.modl.backend.role.data.Permission; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.role.dto.request.ReorderRolesRequest; -import gg.modl.backend.role.dto.request.RoleRequest; -import gg.modl.backend.role.dto.response.RoleResponse; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.service.ServerTimestampService; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.Date; -import java.util.HashSet; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.function.Function; -import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RoleService { - private final StaffRoleMongoRepository staffRoleRepository; - private final StaffMongoRepository staffRepository; - private final PermissionService permissionService; - private final RoleAuthorization roleAuthorization; - private final ServerTimestampService serverTimestampService; - - private final Set orderingRepairedServers = ConcurrentHashMap.newKeySet(); - - public List getAllRoles(Server server) { - repairCustomRoleOrderingOnce(server); - - List roles = staffRoleRepository.findAllOrdered(server); - Map roleCounts = staffRepository.countByRoleId(server); - - return roles.stream() - .map(role -> toRoleResponse(role, roleCounts.getOrDefault(role.getId(), 0))) - .toList(); - } - - private void repairCustomRoleOrderingOnce(Server server) { - String serverId = server.getId(); - if (serverId != null && orderingRepairedServers.contains(serverId)) { - return; - } - fixCustomRoleOrdering(server); - if (serverId != null) { - orderingRepairedServers.add(serverId); - } - } - - private void fixCustomRoleOrdering(Server server) { - List problematicRoles = staffRoleRepository.findCustomRolesWithOrderZero(server); - if (problematicRoles.isEmpty()) { - return; - } - - StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); - int baseOrder = highestRole != null ? Math.max(highestRole.getOrder(), 3) + 1 : 4; - - List sorted = problematicRoles.stream() - .sorted(Comparator.comparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) - .thenComparing(StaffRole::getId)) - .toList(); - - Map orderById = new LinkedHashMap<>(); - int nextOrder = baseOrder; - for (StaffRole role : sorted) { - orderById.put(role.getId(), nextOrder++); - } - staffRoleRepository.bulkRepairOrderFromZero(server, orderById); - } - - private RoleResponse toRoleResponse(StaffRole role, int userCount) { - return new RoleResponse( - role.getId(), - role.getName(), - role.getDescription(), - role.getPermissions(), - role.isDefault(), - role.getOrder(), - userCount, - role.getCreatedAt(), - role.getUpdatedAt() - ); - } - - public Optional getRoleById(Server server, String id) { - StaffRole role = staffRoleRepository.findById(server, id).orElse(null); - - if (role == null) { - return Optional.empty(); - } - - int staffCount = getStaffCountForRole(server, role.getId()); - return Optional.of(toRoleResponse(role, staffCount)); - } - - private int getStaffCountForRole(Server server, String roleId) { - return staffRepository.countByRoleId(server, roleId); - } - - public boolean updateRolePermissions(Server server, String id, List permissions, - RoleAuthorization.PerformerAuthority performer) { - if (RoleAuthorization.isSuperAdminRoleId(id)) { - throw new ForbiddenException("Cannot modify Super Admin role"); - } - - roleAuthorization.requireStaffManage(server, performer, RoleAuthorization.MANAGE_ROLES_PERMISSION); - - StaffRole targetRole = staffRoleRepository.findById(server, id).orElse(null); - if (targetRole == null) { - return false; - } - - Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); - List requested = permissions != null ? permissions : List.of(); - List filtered = requested.stream() - .filter(validPermissions::contains) - .distinct() - .collect(Collectors.toCollection(ArrayList::new)); - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - roleAuthorization.assertHigherAuthority(performerRole, targetRole); - filtered = filterToGrantableOrExisting(performerRole, targetRole, filtered); - } - - targetRole.setPermissions(filtered); - targetRole.setUpdatedAt(new Date()); - staffRoleRepository.saveEntity(server, targetRole); - invalidatePermissionState(server); - return true; - } - - private void invalidatePermissionState(Server server) { - permissionService.evictPermissionCache(); - serverTimestampService.updateStaffPermissionsTimestamp(server); - } - - public RoleResponse createRole(Server server, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { - String roleName = request.name() != null ? request.name().trim() : ""; - ensureRoleNameAvailable(server, roleName, null); - - Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); - List filteredPermissions = request.permissions() - .stream() - .filter(validPermissions::contains) - .toList(); - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - filteredPermissions = filterToGrantablePermissions(performerRole, filteredPermissions); - } - - // Generate unique ID - String id = "custom-" + System.currentTimeMillis() + "-" + UUID.randomUUID().toString().substring(0, 8); - - // Find highest order and add 1 - StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); - int nextOrder = highestRole != null ? highestRole.getOrder() + 1 : 4; - - StaffRole newRole = StaffRole.builder() - .id(id) - .name(roleName) - .description(request.description()) - .permissions(new ArrayList<>(filteredPermissions)) - .isDefault(false) - .order(nextOrder) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - staffRoleRepository.saveEntity(server, newRole); - - return toRoleResponse(newRole, 0); - } - - private List filterToGrantablePermissions(StaffRole performerRole, List permissions) { - return permissions.stream() - .filter(p -> RoleAuthorization.roleGrants(performerRole, p)) - .toList(); - } - - private List filterToGrantableOrExisting(StaffRole performerRole, StaffRole targetRole, List permissions) { - Set existing = new HashSet<>(targetRole.getPermissions() != null ? targetRole.getPermissions() : List.of()); - return permissions.stream() - .filter(p -> RoleAuthorization.roleGrants(performerRole, p) || existing.contains(p)) - .collect(Collectors.toCollection(ArrayList::new)); - } - - private void ensureRoleNameAvailable(Server server, String roleName, String excludeRoleId) { - if (roleName == null || roleName.isBlank()) { - throw new ValidationException("Role name cannot be empty"); - } - - boolean exists = excludeRoleId != null && !excludeRoleId.isBlank() - ? staffRoleRepository.existsByNameIgnoreCaseExcludingId(server, roleName, excludeRoleId) - : staffRoleRepository.existsByNameIgnoreCase(server, roleName); - if (exists) { - throw new ConflictException("A role with this name already exists"); - } - } - - public Optional updateRole(Server server, String id, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { - if (RoleAuthorization.isSuperAdminRoleId(id)) { - throw new ForbiddenException("Cannot modify Super Admin role"); - } - - StaffRole updated = staffRoleRepository.findById(server, id).orElse(null); - if (updated == null) { - return Optional.empty(); - } - - StaffRole performerRole = performer.superAdmin() ? null : roleAuthorization.requirePerformerRole(server, performer); - if (performerRole != null) { - roleAuthorization.assertHigherAuthority(performerRole, updated); - } - - String roleName = request.name() != null ? request.name().trim() : ""; - ensureRoleNameAvailable(server, roleName, id); - - Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); - List filteredPermissions = request.permissions() - .stream() - .filter(validPermissions::contains) - .toList(); - if (performerRole != null) { - filteredPermissions = filterToGrantableOrExisting(performerRole, updated, filteredPermissions); - } - - updated.setName(roleName); - updated.setDescription(request.description()); - updated.setPermissions(new ArrayList<>(filteredPermissions)); - updated.setUpdatedAt(new Date()); - updated = staffRoleRepository.saveEntity(server, updated); - invalidatePermissionState(server); - - int staffCount = getStaffCountForRole(server, updated.getId()); - return Optional.of(toRoleResponse(updated, staffCount)); - } - - public boolean deleteRole(Server server, String id, RoleAuthorization.PerformerAuthority performer) { - if (RoleAuthorization.isSuperAdminRoleId(id)) { - throw new ForbiddenException("Cannot delete Super Admin role"); - } - - StaffRole role = staffRoleRepository.findById(server, id).orElse(null); - if (role == null) { - return false; - } - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - roleAuthorization.assertHigherAuthority(performerRole, role); - } - - int staffCount = getStaffCountForRole(server, role.getId()); - if (staffCount > 0) { - throw new ConflictException("Cannot delete role that is currently assigned to staff members"); - } - - boolean deleted = staffRoleRepository.deleteById(server, id); - if (deleted) { - invalidatePermissionState(server); - } - return deleted; - } - - public void reorderRoles(Server server, ReorderRolesRequest request, RoleAuthorization.PerformerAuthority performer) { - List items = request.roleOrder(); - if (items.isEmpty()) return; - - if (!performer.superAdmin()) { - StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); - int performerOrder = performerRole.getOrder(); - - List ids = items.stream().map(ReorderRolesRequest.RoleOrderItem::id).toList(); - Map rolesById = staffRoleRepository.findByIds(server, ids) - .stream() - .collect(Collectors.toMap(StaffRole::getId, Function.identity())); - - for (ReorderRolesRequest.RoleOrderItem item : items) { - StaffRole targetRole = rolesById.get(item.id()); - if (targetRole == null) continue; - - if (targetRole.getOrder() <= performerOrder) { - throw new ForbiddenException("You do not have authority to reorder this role"); - } - - if (item.order() <= performerOrder) { - throw new ForbiddenException("You cannot promote a role to or above your own authority level"); - } - } - } - - Map orderById = new LinkedHashMap<>(); - for (ReorderRolesRequest.RoleOrderItem item : items) { - orderById.put(item.id(), item.order()); - } - staffRoleRepository.bulkUpdateOrder(server, orderById); - serverTimestampService.updateStaffPermissionsTimestamp(server); - } - - public void createDefaultRoles(Server server) { - List allPunishmentPerms = permissionService.getPunishmentPermissions(server) - .stream() - .map(Permission::id) - .toList(); - List moderatorPunishmentPerms = allPunishmentPerms.stream() - .filter(p -> !p.contains("blacklist")) - .toList(); - - List superAdminPerms = new ArrayList<>(permissionService.getAllPermissionIds(server)); - - List adminPerms = new ArrayList<>(List.of( - "admin.settings.view", "admin.staff.manage", "admin.audit.view", - "punishment.view", "punishment.modify", - "ticket.view.all", "ticket.reply.all", "appeal.modify", "ticket.close.all", - "staff.chat.toggle", "staff.chat.clear", "staff.chat.slow", - "staff.maintenance", "staff.modactions", - "staff.intercept", "staff.chatlogs", "staff.commandlogs" - )); - adminPerms.addAll(allPunishmentPerms); - - List moderatorPerms = new ArrayList<>(List.of( - "punishment.view", "punishment.modify", - "ticket.view.all", "ticket.reply.all", "appeal.modify", "ticket.close.all", - "staff.modactions", - "staff.chatlogs", "staff.commandlogs" - )); - moderatorPerms.addAll(moderatorPunishmentPerms); - - List defaultRoles = List.of( - StaffRole.builder() - .id("super-admin") - .name("Super Admin") - .description("Full access to all features and settings") - .permissions(superAdminPerms) - .isDefault(true) - .order(0) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(), - StaffRole.builder() - .id("admin") - .name("Admin") - .description("Administrative access with some restrictions") - .permissions(adminPerms) - .isDefault(true) - .order(1) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(), - StaffRole.builder() - .id("moderator") - .name("Moderator") - .description("Moderation permissions for punishments and tickets") - .permissions(moderatorPerms) - .isDefault(true) - .order(2) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(), - StaffRole.builder() - .id("helper") - .name("Helper") - .description("Basic support permissions") - .permissions(new ArrayList<>(List.of("ticket.view.all", "ticket.reply.all", "appeal.modify"))) - .isDefault(true) - .order(3) - .createdAt(new Date()) - .updatedAt(new Date()) - .build() - ); - - for (StaffRole role : defaultRoles) { - staffRoleRepository.insertRoleIfAbsent(server, role); - } - permissionService.evictPermissionCache(); - } -} +package gg.modl.backend.role.service; + +import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffRoleMongoRepository; +import gg.modl.backend.role.data.Permission; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.role.dto.request.ReorderRolesRequest; +import gg.modl.backend.role.dto.request.RoleRequest; +import gg.modl.backend.role.dto.response.RoleResponse; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.service.ServerTimestampService; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RoleService { + private final StaffRoleMongoRepository staffRoleRepository; + private final StaffMongoRepository staffRepository; + private final PermissionService permissionService; + private final RoleAuthorization roleAuthorization; + private final ServerTimestampService serverTimestampService; + + private final Set orderingRepairedServers = ConcurrentHashMap.newKeySet(); + + public List getAllRoles(Server server) { + repairCustomRoleOrderingOnce(server); + + List roles = staffRoleRepository.findAllOrdered(server); + Map roleCounts = staffRepository.countByRoleId(server); + + return roles.stream() + .map(role -> toRoleResponse(role, roleCounts.getOrDefault(role.getId(), 0))) + .toList(); + } + + private void repairCustomRoleOrderingOnce(Server server) { + String serverId = server.getId(); + if (serverId != null && orderingRepairedServers.contains(serverId)) { + return; + } + fixCustomRoleOrdering(server); + if (serverId != null) { + orderingRepairedServers.add(serverId); + } + } + + private void fixCustomRoleOrdering(Server server) { + List problematicRoles = staffRoleRepository.findCustomRolesWithOrderZero(server); + if (problematicRoles.isEmpty()) { + return; + } + + StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); + int baseOrder = highestRole != null ? Math.max(highestRole.getOrder(), 3) + 1 : 4; + + List sorted = problematicRoles.stream() + .sorted(Comparator.comparing(StaffRole::getCreatedAt, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(StaffRole::getId)) + .toList(); + + Map orderById = new LinkedHashMap<>(); + int nextOrder = baseOrder; + for (StaffRole role : sorted) { + orderById.put(role.getId(), nextOrder++); + } + staffRoleRepository.bulkRepairOrderFromZero(server, orderById); + } + + private RoleResponse toRoleResponse(StaffRole role, int userCount) { + return new RoleResponse( + role.getId(), + role.getName(), + role.getDescription(), + role.getPermissions(), + role.isDefault(), + role.getOrder(), + userCount, + role.getCreatedAt(), + role.getUpdatedAt() + ); + } + + public Optional getRoleById(Server server, String id) { + StaffRole role = staffRoleRepository.findById(server, id).orElse(null); + + if (role == null) { + return Optional.empty(); + } + + int staffCount = getStaffCountForRole(server, role.getId()); + return Optional.of(toRoleResponse(role, staffCount)); + } + + private int getStaffCountForRole(Server server, String roleId) { + return staffRepository.countByRoleId(server, roleId); + } + + public boolean updateRolePermissions(Server server, String id, List permissions, + RoleAuthorization.PerformerAuthority performer) { + if (RoleAuthorization.isSuperAdminRoleId(id)) { + throw new ForbiddenException("Cannot modify Super Admin role"); + } + + roleAuthorization.requireStaffManage(server, performer, RoleAuthorization.MANAGE_ROLES_PERMISSION); + + StaffRole targetRole = staffRoleRepository.findById(server, id).orElse(null); + if (targetRole == null) { + return false; + } + + Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); + List requested = permissions != null ? permissions : List.of(); + List filtered = requested.stream() + .filter(validPermissions::contains) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + roleAuthorization.assertHigherAuthority(performerRole, targetRole); + filtered = filterToGrantableOrExisting(performerRole, targetRole, filtered); + } + + targetRole.setPermissions(filtered); + targetRole.setUpdatedAt(new Date()); + staffRoleRepository.saveEntity(server, targetRole); + invalidatePermissionState(server); + return true; + } + + private void invalidatePermissionState(Server server) { + permissionService.evictPermissionCache(); + serverTimestampService.updateStaffPermissionsTimestamp(server); + } + + public RoleResponse createRole(Server server, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { + String roleName = request.name() != null ? request.name().trim() : ""; + ensureRoleNameAvailable(server, roleName, null); + + Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); + List filteredPermissions = request.permissions() + .stream() + .filter(validPermissions::contains) + .toList(); + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + filteredPermissions = filterToGrantablePermissions(performerRole, filteredPermissions); + } + + // Generate unique ID + String id = "custom-" + System.currentTimeMillis() + "-" + UUID.randomUUID().toString().substring(0, 8); + + // Find highest order and add 1 + StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); + int nextOrder = highestRole != null ? highestRole.getOrder() + 1 : 4; + + StaffRole newRole = StaffRole.builder() + .id(id) + .name(roleName) + .description(request.description()) + .permissions(new ArrayList<>(filteredPermissions)) + .isDefault(false) + .order(nextOrder) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + staffRoleRepository.saveEntity(server, newRole); + + return toRoleResponse(newRole, 0); + } + + private List filterToGrantablePermissions(StaffRole performerRole, List permissions) { + return permissions.stream() + .filter(p -> RoleAuthorization.roleGrants(performerRole, p)) + .toList(); + } + + private List filterToGrantableOrExisting(StaffRole performerRole, StaffRole targetRole, List permissions) { + Set existing = new HashSet<>(targetRole.getPermissions() != null ? targetRole.getPermissions() : List.of()); + return permissions.stream() + .filter(p -> RoleAuthorization.roleGrants(performerRole, p) || existing.contains(p)) + .collect(Collectors.toCollection(ArrayList::new)); + } + + private void ensureRoleNameAvailable(Server server, String roleName, String excludeRoleId) { + if (roleName == null || roleName.isBlank()) { + throw new ValidationException("Role name cannot be empty"); + } + + boolean exists = excludeRoleId != null && !excludeRoleId.isBlank() + ? staffRoleRepository.existsByNameIgnoreCaseExcludingId(server, roleName, excludeRoleId) + : staffRoleRepository.existsByNameIgnoreCase(server, roleName); + if (exists) { + throw new ConflictException("A role with this name already exists"); + } + } + + public Optional updateRole(Server server, String id, RoleRequest request, RoleAuthorization.PerformerAuthority performer) { + if (RoleAuthorization.isSuperAdminRoleId(id)) { + throw new ForbiddenException("Cannot modify Super Admin role"); + } + + StaffRole updated = staffRoleRepository.findById(server, id).orElse(null); + if (updated == null) { + return Optional.empty(); + } + + StaffRole performerRole = performer.superAdmin() ? null : roleAuthorization.requirePerformerRole(server, performer); + if (performerRole != null) { + roleAuthorization.assertHigherAuthority(performerRole, updated); + } + + String roleName = request.name() != null ? request.name().trim() : ""; + ensureRoleNameAvailable(server, roleName, id); + + Set validPermissions = new HashSet<>(permissionService.getAllPermissionIds(server)); + List filteredPermissions = request.permissions() + .stream() + .filter(validPermissions::contains) + .toList(); + if (performerRole != null) { + filteredPermissions = filterToGrantableOrExisting(performerRole, updated, filteredPermissions); + } + + updated.setName(roleName); + updated.setDescription(request.description()); + updated.setPermissions(new ArrayList<>(filteredPermissions)); + updated.setUpdatedAt(new Date()); + updated = staffRoleRepository.saveEntity(server, updated); + invalidatePermissionState(server); + + int staffCount = getStaffCountForRole(server, updated.getId()); + return Optional.of(toRoleResponse(updated, staffCount)); + } + + public boolean deleteRole(Server server, String id, RoleAuthorization.PerformerAuthority performer) { + if (RoleAuthorization.isSuperAdminRoleId(id)) { + throw new ForbiddenException("Cannot delete Super Admin role"); + } + + StaffRole role = staffRoleRepository.findById(server, id).orElse(null); + if (role == null) { + return false; + } + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + roleAuthorization.assertHigherAuthority(performerRole, role); + } + + int staffCount = getStaffCountForRole(server, role.getId()); + if (staffCount > 0) { + throw new ConflictException("Cannot delete role that is currently assigned to staff members"); + } + + boolean deleted = staffRoleRepository.deleteById(server, id); + if (deleted) { + invalidatePermissionState(server); + } + return deleted; + } + + public void reorderRoles(Server server, ReorderRolesRequest request, RoleAuthorization.PerformerAuthority performer) { + List items = request.roleOrder(); + if (items.isEmpty()) return; + + if (!performer.superAdmin()) { + StaffRole performerRole = roleAuthorization.requirePerformerRole(server, performer); + int performerOrder = performerRole.getOrder(); + + List ids = items.stream().map(ReorderRolesRequest.RoleOrderItem::id).toList(); + Map rolesById = staffRoleRepository.findByIds(server, ids) + .stream() + .collect(Collectors.toMap(StaffRole::getId, Function.identity())); + + for (ReorderRolesRequest.RoleOrderItem item : items) { + StaffRole targetRole = rolesById.get(item.id()); + if (targetRole == null) continue; + + if (targetRole.getOrder() <= performerOrder) { + throw new ForbiddenException("You do not have authority to reorder this role"); + } + + if (item.order() <= performerOrder) { + throw new ForbiddenException("You cannot promote a role to or above your own authority level"); + } + } + } + + Map orderById = new LinkedHashMap<>(); + for (ReorderRolesRequest.RoleOrderItem item : items) { + orderById.put(item.id(), item.order()); + } + staffRoleRepository.bulkUpdateOrder(server, orderById); + serverTimestampService.updateStaffPermissionsTimestamp(server); + } + + public void createDefaultRoles(Server server) { + List allPunishmentPerms = permissionService.getPunishmentPermissions(server) + .stream() + .map(Permission::id) + .toList(); + List moderatorPunishmentPerms = allPunishmentPerms.stream() + .filter(p -> !p.contains("blacklist")) + .toList(); + + List superAdminPerms = new ArrayList<>(permissionService.getAllPermissionIds(server)); + + List adminPerms = new ArrayList<>(List.of( + "admin.settings.view", "admin.staff.manage", "admin.audit.view", + "punishment.view", "punishment.modify", + "ticket.view.all", "ticket.reply.all", "appeal.modify", "ticket.close.all", + "staff.chat.toggle", "staff.chat.clear", "staff.chat.slow", + "staff.maintenance", "staff.modactions", + "staff.intercept", "staff.chatlogs", "staff.commandlogs" + )); + adminPerms.addAll(allPunishmentPerms); + + List moderatorPerms = new ArrayList<>(List.of( + "punishment.view", "punishment.modify", + "ticket.view.all", "ticket.reply.all", "appeal.modify", "ticket.close.all", + "staff.modactions", + "staff.chatlogs", "staff.commandlogs" + )); + moderatorPerms.addAll(moderatorPunishmentPerms); + + List defaultRoles = List.of( + StaffRole.builder() + .id("super-admin") + .name("Super Admin") + .description("Full access to all features and settings") + .permissions(superAdminPerms) + .isDefault(true) + .order(0) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(), + StaffRole.builder() + .id("admin") + .name("Admin") + .description("Administrative access with some restrictions") + .permissions(adminPerms) + .isDefault(true) + .order(1) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(), + StaffRole.builder() + .id("moderator") + .name("Moderator") + .description("Moderation permissions for punishments and tickets") + .permissions(moderatorPerms) + .isDefault(true) + .order(2) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(), + StaffRole.builder() + .id("helper") + .name("Helper") + .description("Basic support permissions") + .permissions(new ArrayList<>(List.of("ticket.view.all", "ticket.reply.all", "appeal.modify"))) + .isDefault(true) + .order(3) + .createdAt(new Date()) + .updatedAt(new Date()) + .build() + ); + + for (StaffRole role : defaultRoles) { + staffRoleRepository.insertRoleIfAbsent(server, role); + } + permissionService.evictPermissionCache(); + } +} diff --git a/src/main/java/gg/modl/backend/server/ServerField.java b/src/main/java/gg/modl/backend/server/ServerField.java index bfe0eae..99dbe10 100644 --- a/src/main/java/gg/modl/backend/server/ServerField.java +++ b/src/main/java/gg/modl/backend/server/ServerField.java @@ -1,5 +1,5 @@ -package gg.modl.backend.server; - +package gg.modl.backend.server; + public final class ServerField { public static final String ADMIN_EMAIL = "adminEmail"; public static final String SUBDOMAIN = "customDomain"; diff --git a/src/main/java/gg/modl/backend/server/ServerResponseMessage.java b/src/main/java/gg/modl/backend/server/ServerResponseMessage.java index 7663bb0..ef89405 100644 --- a/src/main/java/gg/modl/backend/server/ServerResponseMessage.java +++ b/src/main/java/gg/modl/backend/server/ServerResponseMessage.java @@ -1,10 +1,10 @@ -package gg.modl.backend.server; - -public final class ServerResponseMessage { - public static final String REGISTER_SUCCESS = "Registration successful. Please check your email to verify your account."; - public static final String REGISTER_INVALID_SCHEMA = "Invalid registration data provided."; - public static final String REGISTER_EMAIL_EXISTS = "The provided email is already in use!"; - public static final String REGISTER_DOMAIN_EXISTS = "The provided subdomain is already in use!"; - public static final String REGISTER_NAME_EXISTS = "The provided server name is already in use!"; - public static final String REGISTER_RESERVED_SUBDOMAIN = "The provided subdomain is reserved and cannot be used!"; -} +package gg.modl.backend.server; + +public final class ServerResponseMessage { + public static final String REGISTER_SUCCESS = "Registration successful. Please check your email to verify your account."; + public static final String REGISTER_INVALID_SCHEMA = "Invalid registration data provided."; + public static final String REGISTER_EMAIL_EXISTS = "The provided email is already in use!"; + public static final String REGISTER_DOMAIN_EXISTS = "The provided subdomain is already in use!"; + public static final String REGISTER_NAME_EXISTS = "The provided server name is already in use!"; + public static final String REGISTER_RESERVED_SUBDOMAIN = "The provided subdomain is reserved and cannot be used!"; +} diff --git a/src/main/java/gg/modl/backend/server/ServerService.java b/src/main/java/gg/modl/backend/server/ServerService.java index 1d94778..80722e4 100644 --- a/src/main/java/gg/modl/backend/server/ServerService.java +++ b/src/main/java/gg/modl/backend/server/ServerService.java @@ -1,311 +1,311 @@ -package gg.modl.backend.server; - -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.config.ModlCorsProperties; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.ProvisioningStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerProvisioningService; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.Duration; -import java.util.Arrays; -import java.util.Date; -import java.util.HexFormat; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Service; - -@Service -public class ServerService { - private final ServerMongoRepository serverRepository; - private final ServerProvisioningService provisioningService; - private final Set appDomains; - public static final String SERVER_DATABASE_PREFIX = "server_"; - - private final Cache> serverCache = Caffeine.newBuilder() - .maximumSize(500) - .expireAfterWrite(Duration.ofMinutes(5)) - .build(); - - private final Cache apiKeyCache = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofSeconds(60)) - .build(); - - public ServerService( - ServerMongoRepository serverRepository, - ServerProvisioningService provisioningService, - ModlCorsProperties corsProperties - ) { - this.serverRepository = serverRepository; - this.provisioningService = provisioningService; - this.appDomains = Arrays.stream(corsProperties.getAppDomains().split(",")) - .map(String::trim) - .filter(s -> !s.isBlank()) - .collect(Collectors.toSet()); - } - - @Async - public void createServer(@NotNull Server server) { - serverRepository.saveEntity(server); - evictAllServerCaches(); - } - - public void createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail) { - createServer(serverName, customDomain, adminEmail, null, ServerPlan.FREE); - } - - public Server createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail, - @Nullable String emailVerificationToken, @NotNull ServerPlan plan) { - Date now = new Date(); - String databaseName = generateDatabaseName(customDomain); - - String normalizedEmail = EmailAddressUtil.normalize(adminEmail); - if (normalizedEmail == null) { - normalizedEmail = adminEmail; - } - - Server server = new Server(serverName, customDomain, databaseName, normalizedEmail, false, plan); - server.setProvisioningStatus(ProvisioningStatus.PENDING); - server.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - server.setCreatedAt(now); - server.setUpdatedAt(now); - - if (emailVerificationToken != null) { - server.setEmailVerificationToken(emailVerificationToken); - } - - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public String generateDatabaseName(@NotNull String subdomain) { - return SERVER_DATABASE_PREFIX + subdomain; - } - - @Nullable - public Server getServerFromDomain(@NotNull String domain) { - return serverCache.get(domain, key -> { - String subdomain = extractSubdomain(key); - - if (subdomain != null) { - return serverRepository.findByCustomDomain(subdomain); - } - - return serverRepository.findByActiveCustomDomainOverride(key); - }).orElse(null); - } - - public void evictServerCache(@NotNull String domain) { - serverCache.invalidate(domain); - } - - public void evictAllServerCaches() { - serverCache.invalidateAll(); - apiKeyCache.invalidateAll(); - } - - public void evictApiKey(@NotNull String apiKey) { - if (!apiKey.isBlank()) { - apiKeyCache.invalidate(hashApiKey(apiKey)); - } - } - - public boolean isAdminEmailInUse(String adminEmail, String excludingServerId) { - return serverRepository.existsByAdminEmailExcludingId(EmailAddressUtil.normalize(adminEmail), excludingServerId); - } - - public void changeAdminEmail(Server server, String newAdminEmail) { - serverRepository.updateAdminEmail(server.getId(), EmailAddressUtil.normalize(newAdminEmail)); - evictAllServerCaches(); - } - - @Nullable - private String extractSubdomain(@NotNull String domain) { - AppDomainMatch match = matchAppDomain(domain); - return match != null ? match.subdomain() : null; - } - - @Nullable - public String getAppDomain(@NotNull String domain) { - AppDomainMatch match = matchAppDomain(domain); - return match != null ? match.appDomain() : null; - } - - @Nullable - private AppDomainMatch matchAppDomain(@NotNull String domain) { - for (String appDomain : appDomains) { - String suffix = "." + appDomain; - if (domain.endsWith(suffix)) { - String subdomain = domain.substring(0, domain.length() - suffix.length()); - if (!subdomain.isBlank() && !subdomain.contains(".")) { - return new AppDomainMatch(appDomain, subdomain); - } - } - } - return null; - } - - private record AppDomainMatch(String appDomain, String subdomain) {} - - public ServerExistResult doesServerExist(@NotNull String email, @NotNull String serverName, @NotNull String subdomain) { - String normalizedEmail = EmailAddressUtil.normalize(email); - if (normalizedEmail == null) { - normalizedEmail = email; - } - - Server found = serverRepository.findMatchingIdentity(normalizedEmail, serverName, subdomain).orElse(null); - if (found == null) { - return new ServerExistResult(false, false, false); - } - - boolean emailMatch = false, nameMatch = false, domainMatch = false; - - if (found.getAdminEmail().equalsIgnoreCase(normalizedEmail)) { - emailMatch = true; - } - - if (found.getServerName().equals(serverName)) { - nameMatch = true; - } - - if (found.getCustomDomain().equals(subdomain)) { - domainMatch = true; - } - - return new ServerExistResult(emailMatch, nameMatch, domainMatch); - } - - @Nullable - public Server getServerByDatabaseName(@NotNull String databaseName) { - return serverRepository.findByDatabaseName(databaseName).orElse(null); - } - - @Nullable - public Server getServerByApiKey(@NotNull String apiKey) { - if (apiKey.isBlank()) { - return null; - } - - String cacheKey = hashApiKey(apiKey); - Server cached = apiKeyCache.getIfPresent(cacheKey); - if (cached != null) { - return cached; - } - - Server server = serverRepository.findByApiKey(apiKey).orElse(null); - if (server != null) { - apiKeyCache.put(cacheKey, server); - } - return server; - } - - private String hashApiKey(@NotNull String apiKey) { - try { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(apiKey.getBytes(StandardCharsets.UTF_8))); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 algorithm is not available", e); - } - } - - @Nullable - public Server getServerByEmailVerificationToken(@NotNull String token) { - return serverRepository.findByEmailVerificationToken(token).orElse(null); - } - - @Nullable - public Server verifyEmailToken(@NotNull String token) { - Server server = serverRepository.verifyEmailTokenAtomically(token).orElse(null); - - if (server == null) { - return null; - } - - boolean provisioned; - try { - provisioningService.provision(server); - provisioned = true; - } catch (Exception e) { - provisioned = false; - } - - if (provisioned) { - serverRepository.markProvisioningCompleted(server.getId()); - server.setProvisioningStatus(ProvisioningStatus.COMPLETED); - } else { - serverRepository.markProvisioningFailed(server.getId(), "Provisioning failed; awaiting retry."); - server.setProvisioningStatus(ProvisioningStatus.FAILED); - } - - evictAllServerCaches(); - - return server; - } - - @Nullable - public Server getServerByAutoLoginToken(@NotNull String token) { - return serverRepository.findByProvisioningSignInToken(token).orElse(null); - } - - @Nullable - public Server consumeAutoLoginToken(@NotNull String token) { - Server server = serverRepository.consumeProvisioningSignInToken(token, new Date()).orElse(null); - if (server != null) { - evictAllServerCaches(); - } - return server; - } - - public Server setAutoLoginToken(@NotNull Server server, @NotNull String token, @NotNull Date expiresAt) { - server.setProvisioningSignInToken(token); - server.setProvisioningSignInTokenExpiresAt(expiresAt); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public Server clearAutoLoginToken(@NotNull Server server) { - server.setProvisioningSignInToken(null); - server.setProvisioningSignInTokenExpiresAt(null); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - @Nullable - public Server getServerByCliSetupToken(@NotNull String token) { - return serverRepository.findByCliSetupToken(token).orElse(null); - } - - public Server setCliSetupToken(@NotNull Server server, @NotNull String token) { - server.setCliSetupToken(token); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public Server clearCliSetupToken(@NotNull Server server) { - server.setCliSetupToken(null); - server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); - evictAllServerCaches(); - return saved; - } - - public record ServerExistResult(boolean emailMatch, boolean nameMatch, boolean domainMatch) {} -} +package gg.modl.backend.server; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.server.data.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerProvisioningService; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.Arrays; +import java.util.Date; +import java.util.HexFormat; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +public class ServerService { + private final ServerMongoRepository serverRepository; + private final ServerProvisioningService provisioningService; + private final Set appDomains; + public static final String SERVER_DATABASE_PREFIX = "server_"; + + private final Cache> serverCache = Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofMinutes(5)) + .build(); + + private final Cache apiKeyCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofSeconds(60)) + .build(); + + public ServerService( + ServerMongoRepository serverRepository, + ServerProvisioningService provisioningService, + ModlCorsProperties corsProperties + ) { + this.serverRepository = serverRepository; + this.provisioningService = provisioningService; + this.appDomains = Arrays.stream(corsProperties.getAppDomains().split(",")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .collect(Collectors.toSet()); + } + + @Async + public void createServer(@NotNull Server server) { + serverRepository.saveEntity(server); + evictAllServerCaches(); + } + + public void createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail) { + createServer(serverName, customDomain, adminEmail, null, ServerPlan.FREE); + } + + public Server createServer(@NotNull String serverName, @NotNull String customDomain, @NotNull String adminEmail, + @Nullable String emailVerificationToken, @NotNull ServerPlan plan) { + Date now = new Date(); + String databaseName = generateDatabaseName(customDomain); + + String normalizedEmail = EmailAddressUtil.normalize(adminEmail); + if (normalizedEmail == null) { + normalizedEmail = adminEmail; + } + + Server server = new Server(serverName, customDomain, databaseName, normalizedEmail, false, plan); + server.setProvisioningStatus(ProvisioningStatus.PENDING); + server.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + server.setCreatedAt(now); + server.setUpdatedAt(now); + + if (emailVerificationToken != null) { + server.setEmailVerificationToken(emailVerificationToken); + } + + Server saved = serverRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public String generateDatabaseName(@NotNull String subdomain) { + return SERVER_DATABASE_PREFIX + subdomain; + } + + @Nullable + public Server getServerFromDomain(@NotNull String domain) { + return serverCache.get(domain, key -> { + String subdomain = extractSubdomain(key); + + if (subdomain != null) { + return serverRepository.findByCustomDomain(subdomain); + } + + return serverRepository.findByActiveCustomDomainOverride(key); + }).orElse(null); + } + + public void evictServerCache(@NotNull String domain) { + serverCache.invalidate(domain); + } + + public void evictAllServerCaches() { + serverCache.invalidateAll(); + apiKeyCache.invalidateAll(); + } + + public void evictApiKey(@NotNull String apiKey) { + if (!apiKey.isBlank()) { + apiKeyCache.invalidate(hashApiKey(apiKey)); + } + } + + public boolean isAdminEmailInUse(String adminEmail, String excludingServerId) { + return serverRepository.existsByAdminEmailExcludingId(EmailAddressUtil.normalize(adminEmail), excludingServerId); + } + + public void changeAdminEmail(Server server, String newAdminEmail) { + serverRepository.updateAdminEmail(server.getId(), EmailAddressUtil.normalize(newAdminEmail)); + evictAllServerCaches(); + } + + @Nullable + private String extractSubdomain(@NotNull String domain) { + AppDomainMatch match = matchAppDomain(domain); + return match != null ? match.subdomain() : null; + } + + @Nullable + public String getAppDomain(@NotNull String domain) { + AppDomainMatch match = matchAppDomain(domain); + return match != null ? match.appDomain() : null; + } + + @Nullable + private AppDomainMatch matchAppDomain(@NotNull String domain) { + for (String appDomain : appDomains) { + String suffix = "." + appDomain; + if (domain.endsWith(suffix)) { + String subdomain = domain.substring(0, domain.length() - suffix.length()); + if (!subdomain.isBlank() && !subdomain.contains(".")) { + return new AppDomainMatch(appDomain, subdomain); + } + } + } + return null; + } + + private record AppDomainMatch(String appDomain, String subdomain) {} + + public ServerExistResult doesServerExist(@NotNull String email, @NotNull String serverName, @NotNull String subdomain) { + String normalizedEmail = EmailAddressUtil.normalize(email); + if (normalizedEmail == null) { + normalizedEmail = email; + } + + Server found = serverRepository.findMatchingIdentity(normalizedEmail, serverName, subdomain).orElse(null); + if (found == null) { + return new ServerExistResult(false, false, false); + } + + boolean emailMatch = false, nameMatch = false, domainMatch = false; + + if (found.getAdminEmail().equalsIgnoreCase(normalizedEmail)) { + emailMatch = true; + } + + if (found.getServerName().equals(serverName)) { + nameMatch = true; + } + + if (found.getCustomDomain().equals(subdomain)) { + domainMatch = true; + } + + return new ServerExistResult(emailMatch, nameMatch, domainMatch); + } + + @Nullable + public Server getServerByDatabaseName(@NotNull String databaseName) { + return serverRepository.findByDatabaseName(databaseName).orElse(null); + } + + @Nullable + public Server getServerByApiKey(@NotNull String apiKey) { + if (apiKey.isBlank()) { + return null; + } + + String cacheKey = hashApiKey(apiKey); + Server cached = apiKeyCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + + Server server = serverRepository.findByApiKey(apiKey).orElse(null); + if (server != null) { + apiKeyCache.put(cacheKey, server); + } + return server; + } + + private String hashApiKey(@NotNull String apiKey) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(apiKey.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available", e); + } + } + + @Nullable + public Server getServerByEmailVerificationToken(@NotNull String token) { + return serverRepository.findByEmailVerificationToken(token).orElse(null); + } + + @Nullable + public Server verifyEmailToken(@NotNull String token) { + Server server = serverRepository.verifyEmailTokenAtomically(token).orElse(null); + + if (server == null) { + return null; + } + + boolean provisioned; + try { + provisioningService.provision(server); + provisioned = true; + } catch (Exception e) { + provisioned = false; + } + + if (provisioned) { + serverRepository.markProvisioningCompleted(server.getId()); + server.setProvisioningStatus(ProvisioningStatus.COMPLETED); + } else { + serverRepository.markProvisioningFailed(server.getId(), "Provisioning failed; awaiting retry."); + server.setProvisioningStatus(ProvisioningStatus.FAILED); + } + + evictAllServerCaches(); + + return server; + } + + @Nullable + public Server getServerByAutoLoginToken(@NotNull String token) { + return serverRepository.findByProvisioningSignInToken(token).orElse(null); + } + + @Nullable + public Server consumeAutoLoginToken(@NotNull String token) { + Server server = serverRepository.consumeProvisioningSignInToken(token, new Date()).orElse(null); + if (server != null) { + evictAllServerCaches(); + } + return server; + } + + public Server setAutoLoginToken(@NotNull Server server, @NotNull String token, @NotNull Date expiresAt) { + server.setProvisioningSignInToken(token); + server.setProvisioningSignInTokenExpiresAt(expiresAt); + server.setUpdatedAt(new Date()); + Server saved = serverRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public Server clearAutoLoginToken(@NotNull Server server) { + server.setProvisioningSignInToken(null); + server.setProvisioningSignInTokenExpiresAt(null); + server.setUpdatedAt(new Date()); + Server saved = serverRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + @Nullable + public Server getServerByCliSetupToken(@NotNull String token) { + return serverRepository.findByCliSetupToken(token).orElse(null); + } + + public Server setCliSetupToken(@NotNull Server server, @NotNull String token) { + server.setCliSetupToken(token); + server.setUpdatedAt(new Date()); + Server saved = serverRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public Server clearCliSetupToken(@NotNull Server server) { + server.setCliSetupToken(null); + server.setUpdatedAt(new Date()); + Server saved = serverRepository.saveEntity(server); + evictAllServerCaches(); + return saved; + } + + public record ServerExistResult(boolean emailMatch, boolean nameMatch, boolean domainMatch) {} +} diff --git a/src/main/java/gg/modl/backend/server/controller/PanelServerController.java b/src/main/java/gg/modl/backend/server/controller/PanelServerController.java index 140f8b1..eee7b38 100644 --- a/src/main/java/gg/modl/backend/server/controller/PanelServerController.java +++ b/src/main/java/gg/modl/backend/server/controller/PanelServerController.java @@ -1,31 +1,31 @@ -package gg.modl.backend.server.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.proto.modl.v1.ProvisioningStatusResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.core.env.Environment; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_SERVER) -@RequiredArgsConstructor -public class PanelServerController { - private final Environment environment; - - @GetMapping("/test") - public String getTest() { - return environment.getProperty("spring.application.name"); - } - - @GetMapping("/provisioning-status") - public ResponseEntity getProvisioningStatus(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelServerProtoMapper.toProvisioningStatusResponse(server)); - } -} +package gg.modl.backend.server.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.ProvisioningStatusResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.env.Environment; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SERVER) +@RequiredArgsConstructor +public class PanelServerController { + private final Environment environment; + + @GetMapping("/test") + public String getTest() { + return environment.getProperty("spring.application.name"); + } + + @GetMapping("/provisioning-status") + public ResponseEntity getProvisioningStatus(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelServerProtoMapper.toProvisioningStatusResponse(server)); + } +} diff --git a/src/main/java/gg/modl/backend/server/controller/PublicServerController.java b/src/main/java/gg/modl/backend/server/controller/PublicServerController.java index 7a409ff..851841f 100644 --- a/src/main/java/gg/modl/backend/server/controller/PublicServerController.java +++ b/src/main/java/gg/modl/backend/server/controller/PublicServerController.java @@ -1,36 +1,36 @@ -package gg.modl.backend.server.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.proto.modl.v1.ServerAvailabilityRequest; -import gg.modl.proto.modl.v1.ServerAvailabilityResponse; -import gg.modl.proto.modl.v1.ServerRegisterRequest; -import gg.modl.proto.modl.v1.ServerRegisterResponse; -import org.springframework.http.HttpStatus; -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; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_SERVER) -public class PublicServerController { - - @PostMapping("/register") - public ResponseEntity register(@RequestBody ServerRegisterRequest request) { - return ResponseEntity.status(HttpStatus.GONE).body(ServerRegisterResponse.newBuilder() - .setSuccess(false) - .setMessage("Use /v1/public/registration instead.") - .build()); - } - - @PostMapping("/check-availability") - public ResponseEntity checkAvailability(@RequestBody ServerAvailabilityRequest request) { - return ResponseEntity.status(HttpStatus.GONE).body(ServerAvailabilityResponse.newBuilder() - .setEmailAvailable(false) - .setNameAvailable(false) - .setSubdomainAvailable(false) - .setMessage("Use /v1/public/registration/check-availability instead.") - .build()); - } -} +package gg.modl.backend.server.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.proto.modl.v1.ServerAvailabilityRequest; +import gg.modl.proto.modl.v1.ServerAvailabilityResponse; +import gg.modl.proto.modl.v1.ServerRegisterRequest; +import gg.modl.proto.modl.v1.ServerRegisterResponse; +import org.springframework.http.HttpStatus; +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; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_SERVER) +public class PublicServerController { + + @PostMapping("/register") + public ResponseEntity register(@RequestBody ServerRegisterRequest request) { + return ResponseEntity.status(HttpStatus.GONE).body(ServerRegisterResponse.newBuilder() + .setSuccess(false) + .setMessage("Use /v1/public/registration instead.") + .build()); + } + + @PostMapping("/check-availability") + public ResponseEntity checkAvailability(@RequestBody ServerAvailabilityRequest request) { + return ResponseEntity.status(HttpStatus.GONE).body(ServerAvailabilityResponse.newBuilder() + .setEmailAvailable(false) + .setNameAvailable(false) + .setSubdomainAvailable(false) + .setMessage("Use /v1/public/registration/check-availability instead.") + .build()); + } +} diff --git a/src/main/java/gg/modl/backend/server/data/Server.java b/src/main/java/gg/modl/backend/server/data/Server.java index ecf4900..8420483 100644 --- a/src/main/java/gg/modl/backend/server/data/Server.java +++ b/src/main/java/gg/modl/backend/server/data/Server.java @@ -1,205 +1,205 @@ -package gg.modl.backend.server.data; - -import gg.modl.backend.database.CollectionName; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.server.ServerField; -import java.util.Date; -import lombok.Data; -import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.springframework.data.annotation.Id; - -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; -import org.springframework.data.mongodb.core.mapping.FieldType; - -@Document(collection = CollectionName.MODL_SERVERS) -@Data -@RequiredArgsConstructor -@GenerateMongoFields -public class Server implements ServerBillingUpdate { - @NotNull - @Field(name = ServerField.SERVER_NAME, targetType = FieldType.STRING) - private final String serverName; - - @NotNull - @Field(name = ServerField.SUBDOMAIN, targetType = FieldType.STRING) - private final String customDomain; - - @Nullable - @Field(name = "databaseName", targetType = FieldType.STRING) - private final String databaseName; - - @Id - @Field(targetType = FieldType.OBJECT_ID) - private String id; - - @NotNull - @Field(name = ServerField.ADMIN_EMAIL, targetType = FieldType.STRING) - private String adminEmail; - - @NotNull - @Field(name = "emailVerified", targetType = FieldType.BOOLEAN) - private Boolean emailVerified; - - @Nullable - @Field(name = "emailVerificationToken", targetType = FieldType.STRING) - private String emailVerificationToken; - - @Nullable - @Field(name = "cleanupClaimId", targetType = FieldType.STRING) - private String cleanupClaimId; - - @Nullable - @Field(name = "cleanupClaimedAt", targetType = FieldType.DATE_TIME) - private Date cleanupClaimedAt; - - @Nullable - @Field(name = "provisioningStatus", targetType = FieldType.STRING) - private ProvisioningStatus provisioningStatus; - - @Nullable - @Field(name = "provisioningNotes", targetType = FieldType.STRING) - private String provisioningNotes; - - @Nullable - @Field(name = "provisioningSignInToken", targetType = FieldType.STRING) - private String provisioningSignInToken; - - @Nullable - @Field(name = "provisioningSignInTokenExpiresAt", targetType = FieldType.DATE_TIME) - private Date provisioningSignInTokenExpiresAt; - - @NotNull - @Field(name = "plan", targetType = FieldType.STRING) - private ServerPlan plan; - - @Nullable - @Field(name = "subscriptionStatus", targetType = FieldType.STRING) - private SubscriptionStatus subscriptionStatus; - - @Nullable - @Field(name = "currentPeriodStart", targetType = FieldType.DATE_TIME) - private Date currentPeriodStart; - - @Nullable - @Field(name = "currentPeriodEnd", targetType = FieldType.DATE_TIME) - private Date currentPeriodEnd; - - @Nullable - @Field(name = "stripeCustomerId", targetType = FieldType.STRING) - private String stripeCustomerId; - - @Nullable - @Field(name = "stripeSubscriptionId", targetType = FieldType.STRING) - private String stripeSubscriptionId; - - @Nullable - @Field(name = "aiRequestsCurrentPeriod", targetType = FieldType.INT64) - private Long aiRequestsCurrentPeriod; // AI requests used in current billing period - - @Nullable - @Field(name = "usageBillingEnabled", targetType = FieldType.BOOLEAN) - private Boolean usageBillingEnabled; // Whether to charge for overages - - @Nullable - @Field(name = "usageBillingUpdatedAt", targetType = FieldType.DATE_TIME) - private Date usageBillingUpdatedAt; - - @Nullable - @Field(name = "storageUsedBytes", targetType = FieldType.INT64) - private Long storageUsedBytes; - - @Nullable - @Field(name = "maxStorageLimitBytes", targetType = FieldType.INT64) - private Long maxStorageLimitBytes; - - @Nullable - @Field(name = "maxAiOverageRequests", targetType = FieldType.INT64) - private Long maxAiOverageRequests; - - @Nullable - @Field(name = "migrationFileSizeLimit", targetType = FieldType.INT64) - private Long migrationFileSizeLimit; // Custom migration file size limit in bytes - - @Nullable - @Field(name = ServerField.CUSTOM_DOMAIN, targetType = FieldType.STRING) - private String customDomainOverride; - - @Nullable - @Field(name = ServerField.CUSTOM_DOMAIN_STATUS, targetType = FieldType.STRING) - private CustomDomainStatus customDomainStatus; - - @Nullable - @Field(name = "customDomainLastChecked", targetType = FieldType.DATE_TIME) - private Date customDomainLastChecked; - - @Nullable - @Field(name = "customDomainError", targetType = FieldType.STRING) - private String customDomainError; - - @Nullable - @Field(name = "customDomainCloudflareId", targetType = FieldType.STRING) - private String customDomainCloudflareId; - - @Nullable - @Field(name = ServerField.CUSTOM_DOMAIN_GRANDFATHERED, targetType = FieldType.BOOLEAN) - private Boolean customDomainGrandfathered; - - @Nullable - @Field(name = "betaTester", targetType = FieldType.BOOLEAN) - private Boolean betaTester; - - @Nullable - @Field(name = "betaTesterCreatedAt", targetType = FieldType.DATE_TIME) - private Date betaTesterCreatedAt; - - @Nullable - @Field(name = "betaTesterCreatedBy", targetType = FieldType.STRING) - private String betaTesterCreatedBy; - - @Nullable - @Field(name = "cliSetupToken", targetType = FieldType.STRING) - private String cliSetupToken; - - @Nullable - @Field(name = "apiKey", targetType = FieldType.STRING) - private String apiKey; - - @Nullable - @Field(name = "onlinePlayerCount", targetType = FieldType.INT64) - private Long onlinePlayerCount; - - @Nullable - @Field(name = "userCount", targetType = FieldType.INT64) - private Long userCount; - - @Nullable - @Field(name = "ticketCount", targetType = FieldType.INT64) - private Long ticketCount; - - @Nullable - @Field(name = "lastStatsUpdatedAt", targetType = FieldType.DATE_TIME) - private Date lastStatsUpdatedAt; - - @Nullable - @Field(name = "lastActivityAt", targetType = FieldType.DATE_TIME) - private Date lastActivityAt; - - @Nullable - @Field(name = "createdAt", targetType = FieldType.DATE_TIME) - private Date createdAt; - - @Nullable - @Field(name = "updatedAt", targetType = FieldType.DATE_TIME) - private Date updatedAt; - - @Nullable - @Field(name = "staffPermissionsUpdatedAt", targetType = FieldType.DATE_TIME) - private Date staffPermissionsUpdatedAt; - - @Nullable - @Field(name = "punishmentTypesUpdatedAt", targetType = FieldType.DATE_TIME) - private Date punishmentTypesUpdatedAt; -} +package gg.modl.backend.server.data; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.server.ServerField; +import java.util.Date; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.data.annotation.Id; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldType; + +@Document(collection = CollectionName.MODL_SERVERS) +@Data +@RequiredArgsConstructor +@GenerateMongoFields +public class Server implements ServerBillingUpdate { + @NotNull + @Field(name = ServerField.SERVER_NAME, targetType = FieldType.STRING) + private final String serverName; + + @NotNull + @Field(name = ServerField.SUBDOMAIN, targetType = FieldType.STRING) + private final String customDomain; + + @Nullable + @Field(name = "databaseName", targetType = FieldType.STRING) + private final String databaseName; + + @Id + @Field(targetType = FieldType.OBJECT_ID) + private String id; + + @NotNull + @Field(name = ServerField.ADMIN_EMAIL, targetType = FieldType.STRING) + private String adminEmail; + + @NotNull + @Field(name = "emailVerified", targetType = FieldType.BOOLEAN) + private Boolean emailVerified; + + @Nullable + @Field(name = "emailVerificationToken", targetType = FieldType.STRING) + private String emailVerificationToken; + + @Nullable + @Field(name = "cleanupClaimId", targetType = FieldType.STRING) + private String cleanupClaimId; + + @Nullable + @Field(name = "cleanupClaimedAt", targetType = FieldType.DATE_TIME) + private Date cleanupClaimedAt; + + @Nullable + @Field(name = "provisioningStatus", targetType = FieldType.STRING) + private ProvisioningStatus provisioningStatus; + + @Nullable + @Field(name = "provisioningNotes", targetType = FieldType.STRING) + private String provisioningNotes; + + @Nullable + @Field(name = "provisioningSignInToken", targetType = FieldType.STRING) + private String provisioningSignInToken; + + @Nullable + @Field(name = "provisioningSignInTokenExpiresAt", targetType = FieldType.DATE_TIME) + private Date provisioningSignInTokenExpiresAt; + + @NotNull + @Field(name = "plan", targetType = FieldType.STRING) + private ServerPlan plan; + + @Nullable + @Field(name = "subscriptionStatus", targetType = FieldType.STRING) + private SubscriptionStatus subscriptionStatus; + + @Nullable + @Field(name = "currentPeriodStart", targetType = FieldType.DATE_TIME) + private Date currentPeriodStart; + + @Nullable + @Field(name = "currentPeriodEnd", targetType = FieldType.DATE_TIME) + private Date currentPeriodEnd; + + @Nullable + @Field(name = "stripeCustomerId", targetType = FieldType.STRING) + private String stripeCustomerId; + + @Nullable + @Field(name = "stripeSubscriptionId", targetType = FieldType.STRING) + private String stripeSubscriptionId; + + @Nullable + @Field(name = "aiRequestsCurrentPeriod", targetType = FieldType.INT64) + private Long aiRequestsCurrentPeriod; // AI requests used in current billing period + + @Nullable + @Field(name = "usageBillingEnabled", targetType = FieldType.BOOLEAN) + private Boolean usageBillingEnabled; // Whether to charge for overages + + @Nullable + @Field(name = "usageBillingUpdatedAt", targetType = FieldType.DATE_TIME) + private Date usageBillingUpdatedAt; + + @Nullable + @Field(name = "storageUsedBytes", targetType = FieldType.INT64) + private Long storageUsedBytes; + + @Nullable + @Field(name = "maxStorageLimitBytes", targetType = FieldType.INT64) + private Long maxStorageLimitBytes; + + @Nullable + @Field(name = "maxAiOverageRequests", targetType = FieldType.INT64) + private Long maxAiOverageRequests; + + @Nullable + @Field(name = "migrationFileSizeLimit", targetType = FieldType.INT64) + private Long migrationFileSizeLimit; // Custom migration file size limit in bytes + + @Nullable + @Field(name = ServerField.CUSTOM_DOMAIN, targetType = FieldType.STRING) + private String customDomainOverride; + + @Nullable + @Field(name = ServerField.CUSTOM_DOMAIN_STATUS, targetType = FieldType.STRING) + private CustomDomainStatus customDomainStatus; + + @Nullable + @Field(name = "customDomainLastChecked", targetType = FieldType.DATE_TIME) + private Date customDomainLastChecked; + + @Nullable + @Field(name = "customDomainError", targetType = FieldType.STRING) + private String customDomainError; + + @Nullable + @Field(name = "customDomainCloudflareId", targetType = FieldType.STRING) + private String customDomainCloudflareId; + + @Nullable + @Field(name = ServerField.CUSTOM_DOMAIN_GRANDFATHERED, targetType = FieldType.BOOLEAN) + private Boolean customDomainGrandfathered; + + @Nullable + @Field(name = "betaTester", targetType = FieldType.BOOLEAN) + private Boolean betaTester; + + @Nullable + @Field(name = "betaTesterCreatedAt", targetType = FieldType.DATE_TIME) + private Date betaTesterCreatedAt; + + @Nullable + @Field(name = "betaTesterCreatedBy", targetType = FieldType.STRING) + private String betaTesterCreatedBy; + + @Nullable + @Field(name = "cliSetupToken", targetType = FieldType.STRING) + private String cliSetupToken; + + @Nullable + @Field(name = "apiKey", targetType = FieldType.STRING) + private String apiKey; + + @Nullable + @Field(name = "onlinePlayerCount", targetType = FieldType.INT64) + private Long onlinePlayerCount; + + @Nullable + @Field(name = "userCount", targetType = FieldType.INT64) + private Long userCount; + + @Nullable + @Field(name = "ticketCount", targetType = FieldType.INT64) + private Long ticketCount; + + @Nullable + @Field(name = "lastStatsUpdatedAt", targetType = FieldType.DATE_TIME) + private Date lastStatsUpdatedAt; + + @Nullable + @Field(name = "lastActivityAt", targetType = FieldType.DATE_TIME) + private Date lastActivityAt; + + @Nullable + @Field(name = "createdAt", targetType = FieldType.DATE_TIME) + private Date createdAt; + + @Nullable + @Field(name = "updatedAt", targetType = FieldType.DATE_TIME) + private Date updatedAt; + + @Nullable + @Field(name = "staffPermissionsUpdatedAt", targetType = FieldType.DATE_TIME) + private Date staffPermissionsUpdatedAt; + + @Nullable + @Field(name = "punishmentTypesUpdatedAt", targetType = FieldType.DATE_TIME) + private Date punishmentTypesUpdatedAt; +} diff --git a/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java b/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java index bceb796..099a189 100644 --- a/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java +++ b/src/main/java/gg/modl/backend/server/data/ServerBillingUpdate.java @@ -1,29 +1,29 @@ -package gg.modl.backend.server.data; - -import java.util.Date; - -public interface ServerBillingUpdate { - String getStripeSubscriptionId(); - - void setStripeCustomerId(String stripeCustomerId); - - void setStripeSubscriptionId(String stripeSubscriptionId); - - void setSubscriptionStatus(SubscriptionStatus subscriptionStatus); - - void setPlan(ServerPlan plan); - - void setCurrentPeriodStart(Date currentPeriodStart); - - void setCurrentPeriodEnd(Date currentPeriodEnd); - - void setUsageBillingEnabled(Boolean usageBillingEnabled); - - void setUsageBillingUpdatedAt(Date usageBillingUpdatedAt); - - void setMaxStorageLimitBytes(Long maxStorageLimitBytes); - - void setMaxAiOverageRequests(Long maxAiOverageRequests); - - void setMigrationFileSizeLimit(Long migrationFileSizeLimit); -} +package gg.modl.backend.server.data; + +import java.util.Date; + +public interface ServerBillingUpdate { + String getStripeSubscriptionId(); + + void setStripeCustomerId(String stripeCustomerId); + + void setStripeSubscriptionId(String stripeSubscriptionId); + + void setSubscriptionStatus(SubscriptionStatus subscriptionStatus); + + void setPlan(ServerPlan plan); + + void setCurrentPeriodStart(Date currentPeriodStart); + + void setCurrentPeriodEnd(Date currentPeriodEnd); + + void setUsageBillingEnabled(Boolean usageBillingEnabled); + + void setUsageBillingUpdatedAt(Date usageBillingUpdatedAt); + + void setMaxStorageLimitBytes(Long maxStorageLimitBytes); + + void setMaxAiOverageRequests(Long maxAiOverageRequests); + + void setMigrationFileSizeLimit(Long migrationFileSizeLimit); +} diff --git a/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java b/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java index d35ee13..1f6baef 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java +++ b/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java @@ -1,595 +1,595 @@ -package gg.modl.backend.server.service; - -import gg.modl.backend.database.MongoIndexBootstrapService; -import gg.modl.backend.database.mongo.TenantMongoAccess; -import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; -import gg.modl.backend.database.mongo.repository.KnowledgebaseCategoryMongoRepository; -import gg.modl.backend.database.mongo.repository.SettingsMongoRepository; -import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; -import gg.modl.backend.role.service.RoleService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; -import java.util.ArrayList; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class ServerProvisioningService { - private static final String AI_CHAT_ABUSE_CONFIG_ID = "6"; - private static final String AI_ANTI_SOCIAL_CONFIG_ID = "7"; - - private final TenantMongoAccess tenantMongoAccess; - private final MongoIndexBootstrapService mongoIndexBootstrapService; - private final SettingsMongoRepository settingsRepository; - private final KnowledgebaseCategoryMongoRepository knowledgebaseCategoryRepository; - private final HomepageCardMongoRepository homepageCardRepository; - private final RoleService roleService; - - public void provision(Server server) { - List failures = new ArrayList<>(); - Exception firstError = null; - - for (Map.Entry step : provisioningSteps(server).entrySet()) { - try { - step.getValue().run(); - } catch (Exception e) { - log.error("[Provisioning] Step '{}' failed for server: {}", step.getKey(), server.getCustomDomain(), e); - failures.add(step.getKey() + ": " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); - if (firstError == null) { - firstError = e; - } - } - } - - if (!failures.isEmpty()) { - throw new ProvisioningException( - "Provisioning failed for " + server.getCustomDomain() + " [" + String.join("; ", failures) + "]", - firstError); - } - } - - private Map provisioningSteps(Server server) { - Map steps = new LinkedHashMap<>(); - steps.put("createTenantIndexes", () -> mongoIndexBootstrapService.createTenantIndexes(tenantMongoAccess.forServer(server))); - steps.put("seedAIModerationSettings", () -> seedAIModerationSettings(server)); - steps.put("seedTicketForms", () -> seedTicketForms(server)); - steps.put("seedQuickResponses", () -> seedQuickResponses(server)); - steps.put("seedGeneralSettings", () -> seedGeneralSettings(server)); - steps.put("seedTicketLabelSettings", () -> seedTicketLabelSettings(server)); - steps.put("seedKnowledgebaseCategories", () -> seedKnowledgebaseCategories(server)); - steps.put("seedHomepageCards", () -> seedHomepageCards(server)); - steps.put("createDefaultRoles", () -> roleService.createDefaultRoles(server)); - return steps; - } - - private void seedAIModerationSettings(Server server) { - if (settingsExist(server, "aiModerationSettings")) { - return; - } - - Map chatAbuseConfig = new LinkedHashMap<>(); - chatAbuseConfig.put("id", AI_CHAT_ABUSE_CONFIG_ID); - chatAbuseConfig.put("name", "Chat Abuse"); - chatAbuseConfig.put("aiDescription", - "Chat abuse is the act of spamming, excessive profanity, abusive language, inappropriate topics or jokes, and misleading information"); - chatAbuseConfig.put("enabled", true); - - Map antiSocialConfig = new LinkedHashMap<>(); - antiSocialConfig.put("id", AI_ANTI_SOCIAL_CONFIG_ID); - antiSocialConfig.put("name", "Anti Social"); - antiSocialConfig.put("aiDescription", - "Anti social is the act of harassing, threatening, black-mailing, or otherwise abusing another player or group of players. This includes bigotry and other forms of discrimination against protected classes."); - antiSocialConfig.put("enabled", true); - - Map aiPunishmentConfigs = new LinkedHashMap<>(); - aiPunishmentConfigs.put(AI_CHAT_ABUSE_CONFIG_ID, chatAbuseConfig); - aiPunishmentConfigs.put(AI_ANTI_SOCIAL_CONFIG_ID, antiSocialConfig); - - Map data = new LinkedHashMap<>(); - data.put("enableAIReview", false); - data.put("enableAutomatedActions", false); - data.put("aiPunishmentConfigs", aiPunishmentConfigs); - - settingsRepository.saveEntity(server, newSettingsDocument("aiModerationSettings", data)); - } - - private boolean settingsExist(Server server, String type) { - return settingsRepository.existsByType(server, type); - } - - private Settings newSettingsDocument(String type, Map data) { - return new Settings(null, type, data, 0L, new Date()); - } - - private void seedTicketForms(Server server) { - if (settingsExist(server, "ticketForms")) { - return; - } - - // Bug report form - Map bugForm = new LinkedHashMap<>(); - bugForm.put("fields", List.of( - formField("1753243804677", "textarea", "Bug Description", "Describe the bug in full detail", true, 3, "1753243782799"), - formField("1753243846548", "textarea", "Environment", "Game/server, client version, and any other relevant conditions", true, 3, "1753243782799"), - formField("1753243865490", "textarea", "Steps to reproduce", "Detailed description on how we can reproduce the bug", true, 2, "1753243782799"), - formField("1753243883567", "textarea", "Any other information?", null, false, 3, "1753243782799"), - formField("1753243946458", "file_upload", "Attachments", "Upload relevant attachments to help us squash this bug.", false, 4, "1753243782799") - )); - bugForm.put("sections", List.of( - formSection("1753243782799", "General", 0, false) - )); - - // Support form - Map supportForm = new LinkedHashMap<>(); - supportForm.put("fields", List.of( - formField("1753243961223", "textarea", "Description", "How can we assist you?", true, 0, "1753243900648"), - formField("1753243997358", "file_upload", "Attachments", "Upload any relevant attachments.", false, 1, "1753243900648") - )); - supportForm.put("sections", List.of( - formSection("1753243900648", "General", 0, false) - )); - - // Application form - Map applicationForm = buildApplicationForm(); - - Map data = new LinkedHashMap<>(); - data.put("bug", bugForm); - data.put("support", supportForm); - data.put("application", applicationForm); - - settingsRepository.saveEntity(server, newSettingsDocument("ticketForms", data)); - } - - private Map buildApplicationForm() { - List> fields = new ArrayList<>(); - - // General section fields - fields.add(formField("1753244313811", "text", "First Name", null, true, 0, "1753244011186")); - fields.add(formField("1753244038340", "text", "Discord username", "Please use the new username format, starting with an @.", true, 1, "1753244011186")); - fields.add(formField("1753244070995", "text", "Age", null, true, 2, "1753244011186")); - fields.add(formField("1753244166086", "text", "Region & Timezone", "Ex: NA, Eastern Time", true, 3, "1753244011186")); - fields.add( - formField("1753244525756", "text", "What languages can you speak?", "If you speak more than one, please list your level of fluency in each.", true, - 4, "1753244011186")); - fields.add( - formField("1753244114967", "checkbox", "Do you have access to both a working microphone and recording software?", null, true, 5, "1753244011186")); - - // Position dropdown with section mapping - Map positionField = new LinkedHashMap<>(); - positionField.put("id", "1753244244863"); - positionField.put("type", "dropdown"); - positionField.put("label", "Position"); - positionField.put("description", "What position are you applying for?"); - positionField.put("required", true); - positionField.put("options", List.of("Moderator", "Builder", "Developer", "Media")); - positionField.put("order", 6); - positionField.put("sectionId", "1753244011186"); - positionField.put("optionSectionMapping", Map.of( - "Moderator", "1753244183109", - "Builder", "1753244277605", - "Developer", "1753244282540", - "Media", "1753244286527" - )); - fields.add(positionField); - - // Moderator section fields - fields.add(formField("1753244506417", "textarea", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", - "If so, please explain each occurrence.", true, 0, "1753244183109")); - fields.add(formField("1753244551193", "textarea", "Describe your moderation background and previous experience.", - "The more detail the better. This doesn't have to be limited to Minecraft servers, as we welcome any previous experience in moderating Discord servers or even other game communities. Please provide references and proof for your more notable experiences.", - true, 1, "1753244183109")); - fields.add(formField("1753244585381", "textarea", "Why do you want to become a moderator on this server?", - "Again, the more detail on this question the better. Providing us with as much detail as possible will help us understand your motivation and will to become a moderator!", - true, 9, "1753244183109")); - fields.add(formField("1753244603377", "text", "How much time do you see yourself committing to the server?", null, true, 10, "1753244183109")); - fields.add(formField("1753244687326", "textarea", - "You are a Moderator with the ability to mute and ban. You are playing on the server with a friend and come across a player who you think is hacking. They kill your friend, but then you kill them. What do you do in this situation?", - null, true, 11, "1753244183109")); - fields.add(formField("1753244762984", "textarea", - "You are a Moderator with the ability to mute and ban. You are spectating a player who you believe is hacking, but multiple chat reports come in about a player in another gamemode who is being violently disruptive in chat. Somehow, you are the only moderator online. How do you handle the two situations?", - null, true, 12, "1753244183109")); - fields.add(formField("1753244861431", "textarea", - "You are a Moderator with the ability to mute and ban. You see 5+ reports come in accusing the same player of breaking the chat rules. You join the server where the situation is taking place and open the accused player's recent chat history. You see that they were being rude, but haven't actually broken a rule. When you decide that they are not guilty, the same group reports the player again, and sends you multiple private messages calling you a bad moderator for not muting the player. What's the first step in dealing with this situation? Explain how this step will move towards resolving the conflict.", - null, true, 13, "1753244183109")); - fields.add(formField("1753244931272", "textarea", - "You are the newest Moderator on the team. While you are spectating a game, you witness a Sr. Moderator mining suspiciously. In a matter of minutes, you get enough evidence that suggests that the Sr. Moderator may likely be x-raying. Suddenly, they head to the surface and do nothing suspicious for the rest of your time spectating them. How do you proceed?", - null, true, 14, "1753244183109")); - fields.add(formField("1753245023983", "textarea", - "You are a Moderator with the ability to mute and ban. You notice a well-known streamer/YouTuber closely affiliated with the server is nicked. They message a player words encouraging suicide under their disguised alias. What steps do you take to resolve the situation?", - null, true, 15, "1753244183109")); - - // Builder section fields - fields.add(formField("1753245081481", "textarea", "Do you have experience building for other servers?", null, true, 16, "1753244277605")); - fields.add(formField("1753245137086", "textarea", "Please provide proof of previous work in link form here (Imgur, YouTube, etc)", null, true, 17, - "1753244277605")); - fields.add(formField("1753245154307", "textarea", "Anything else you would like to say?", null, false, 23, "1753244277605")); - - // Developer section fields - fields.add(formField("1753245191475", "textarea", "Why do you want to be a developer on this server?", null, true, 0, "1753244282540")); - fields.add(formField("1753245262717", "textarea", "Do you have experience developing for other servers?", null, true, 1, "1753244282540")); - fields.add(formField("1753245280773", "text", "Please provide proof of previous work in the form of a GitHub link", null, true, 2, "1753244282540")); - fields.add(formField("1753245291714", "textarea", "Anything else you would like to say?", null, false, 3, "1753244282540")); - - // Media section fields - fields.add(formField("1753245348514", "text", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", - "If so, please explain each occurrence.", true, 23, "1753244286527")); - fields.add(formField("1753245358313", "text", "A link to your YouTube and/or Stream Channel", null, true, 24, "1753244286527")); - fields.add(formField("1753245471763", "checkbox", - "We will email the contact email listed on the channel for proof of ownership, please verify it is accurate and actively monitored.", null, true, - 25, "1753244286527")); - fields.add(formField("1753245511672", "textarea", "Anything else you would like to say?", null, false, 26, "1753244286527")); - - List> sections = List.of( - formSection("1753244011186", "General", 0, false), - formSection("1753244183109", "Moderator", 1, true), - formSection("1753244277605", "Builder", 2, true), - formSection("1753244282540", "Developer", 3, true), - formSection("1753244286527", "Media", 4, true) - ); - - Map form = new LinkedHashMap<>(); - form.put("fields", fields); - form.put("sections", sections); - return form; - } - - private Map formField(String id, String type, String label, String description, boolean required, int order, String sectionId) { - Map field = new LinkedHashMap<>(); - field.put("id", id); - field.put("type", type); - field.put("label", label); - if (description != null) { - field.put("description", description); - } - field.put("required", required); - field.put("order", order); - field.put("sectionId", sectionId); - return field; - } - - private Map formSection(String id, String title, int order, boolean hideByDefault) { - Map section = new LinkedHashMap<>(); - section.put("id", id); - section.put("title", title); - section.put("order", order); - section.put("hideByDefault", hideByDefault); - return section; - } - - private void seedQuickResponses(Server server) { - if (settingsExist(server, "quickResponses")) { - return; - } - - List> categories = new ArrayList<>(); - - // Chat Report Actions - categories.add(quickResponseCategory("chat_report_actions", "Chat Report Actions", List.of("chat_report"), 1, List.of( - quickResponseAction("accept_report", "Accept Report", - "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, - true, true, null), - quickResponseAction("reject_insufficient_chat", "More Evidence", - "Thank you for submitting this chat report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, - false, false, null), - quickResponseAction("reject_no_violation_chat", "Reject - No Violation", - "Thank you for submitting this chat report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", - 3, true, false, null) - ))); - - // Player Report Actions - categories.add(quickResponseCategory("player_report_actions", "Player Report Actions", List.of("player_report"), 2, List.of( - quickResponseAction("accept_report", "Accept Report", - "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, - true, true, null), - quickResponseAction("reject_insufficient_player", "More Evidence", - "Thank you for submitting this player report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, - false, false, null), - quickResponseAction("reject_no_violation_player", "Reject - No Violation", - "Thank you for submitting this player report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", - 3, true, false, null) - ))); - - // Appeal Actions - categories.add(quickResponseCategory("appeal_actions", "Appeal Actions", List.of("appeal"), 3, List.of( - quickResponseAction("pardon_full", "Pardon - Full", - "After reviewing your appeal, we have decided to remove the punishment completely. We apologize for any inconvenience.", 1, true, false, - "pardon"), - quickResponseAction("reduce_punishment", "Reduce Punishment", - "We have reviewed your appeal and decided to reduce the duration of your punishment. Please check your punishment details for the updated duration.", - 2, true, false, "reduce"), - quickResponseAction("reject_upheld", "Reject - Upheld", - "After careful consideration of your appeal, we have decided to uphold the original punishment.", 3, true, false, "reject"), - quickResponseAction("need_more_info_appeal", "Need More Information", - "We need additional information to process your appeal. Please provide more details about your situation.", 4, false, false, null) - ))); - - // Staff Application Actions - categories.add(quickResponseCategory("application_actions", "Staff Application Actions", List.of("application"), 4, List.of( - quickResponseAction("accept_builder", "Accept - Builder", - "Congratulations! Your Builder application has been accepted. Welcome to the Builder team! You will receive further instructions and permissions shortly.", - 1, true, false, null), - quickResponseAction("accept_helper", "Accept - Helper", - "Congratulations! Your Helper application has been accepted. Welcome to the Helper team! You will receive further instructions and permissions shortly.", - 2, true, false, null), - quickResponseAction("accept_developer", "Accept - Developer", - "Congratulations! Your Developer application has been accepted. Welcome to the Developer team! You will receive further instructions and permissions shortly.", - 3, true, false, null), - quickResponseAction("reject_application", "Reject Application", - "Thank you for your interest in joining our team. Unfortunately, we have decided not to move forward with your application at this time. You may reapply in the future.", - 4, true, false, null), - quickResponseAction("pending_review", "Pending Review", - "Thank you for your application. We are currently reviewing it and will get back to you soon.", 5, false, false, null), - quickResponseAction("interview_scheduled", "Interview Scheduled", - "Your application has progressed to the interview stage. Please check your email for interview details.", 6, false, false, null), - quickResponseAction("need_more_info_app", "Need More Information", - "We need additional information about your application. Please provide more details about your experience and qualifications.", 7, false, false, - null) - ))); - - // Bug Report Actions - categories.add(quickResponseCategory("bug_actions", "Bug Report Actions", List.of("bug"), 5, List.of( - quickResponseAction("completed", "Fixed", "Thank you for reporting this bug. We have fixed the issue and it will be included in our next update.", - 1, true, false, null), - quickResponseAction("investigating", "Investigating", - "Thank you for this bug report. We are currently investigating the issue and will provide updates as they become available.", 2, false, false, - null), - quickResponseAction("need_more_info", "Need More Info", - "Thank you for this bug report. We need additional information to investigate this issue. Please provide more details about how to reproduce this bug.", - 3, false, false, null), - quickResponseAction("duplicate", "Duplicate", - "This bug has been identified as a duplicate of an existing issue. We appreciate your report and are working on a fix.", 4, true, false, null), - quickResponseAction("cannot_reproduce", "Cannot Reproduce", - "We were unable to reproduce this issue. If you continue to experience this problem, please provide additional details.", 5, true, false, null) - ))); - - // Support Actions - categories.add(quickResponseCategory("support_actions", "Support Actions", List.of("support"), 6, List.of( - quickResponseAction("resolved", "Resolved", - "Your support request has been resolved. If you need further assistance, please feel free to create a new ticket.", 1, true, false, null), - quickResponseAction("escalated", "Escalated", - "Your support request has been escalated to our specialized team. They will contact you with additional information.", 2, false, false, null), - quickResponseAction("need_info_support", "Need More Info", - "We need additional information to assist you with your request. Please provide more details about your issue.", 3, false, false, null) - ))); - - // General Actions - categories.add( - quickResponseCategory("general_actions", "General Actions", List.of("player_report", "chat_report", "bug", "appeal", "support", "application"), 7, - List.of( - quickResponseAction("acknowledge", "Acknowledge", "Thank you for your message. We have received your ticket and will review it shortly.", 1, - false, false, null), - quickResponseAction("follow_up", "Follow Up", - "We are following up on your ticket. Please let us know if you have any additional information or questions.", 2, false, false, null) - ))); - - Map data = new LinkedHashMap<>(); - data.put("categories", categories); - - settingsRepository.saveEntity(server, newSettingsDocument("quickResponses", data)); - } - - private Map quickResponseCategory(String id, String name, List ticketTypes, int order, List> actions) { - Map category = new LinkedHashMap<>(); - category.put("id", id); - category.put("name", name); - category.put("ticketTypes", ticketTypes); - category.put("order", order); - category.put("actions", actions); - return category; - } - - private Map quickResponseAction(String id, String name, String message, int order, boolean closeTicket, boolean showPunishment, String appealAction) { - Map action = new LinkedHashMap<>(); - action.put("id", id); - action.put("name", name); - action.put("message", message); - action.put("order", order); - action.put("closeTicket", closeTicket); - if (showPunishment) { - action.put("showPunishment", true); - } - if (appealAction != null) { - action.put("appealAction", appealAction); - } - return action; - } - - // Helper methods for building form data structures - - private void seedGeneralSettings(Server server) { - if (settingsExist(server, "general")) { - return; - } - - Map data = new LinkedHashMap<>(); - data.put("serverDisplayName", ""); - data.put("discordWebhookUrl", ""); - data.put("homepageIconUrl", ""); - data.put("panelIconUrl", ""); - - settingsRepository.saveEntity(server, newSettingsDocument("general", data)); - } - - private void seedTicketLabelSettings(Server server) { - if (settingsExist(server, "ticketLabels")) { - return; - } - - List> labels = List.of( - labelMap("high priority", "#e74c3c", "High priority tickets"), - labelMap("needs admin review", "#f39c12", "Tickets that need review"), - labelMap("in progress", "#2ecc71", "Tickets being worked on"), - labelMap("won't fix", "#6b7280", "Issues that won't be fixed"), - labelMap("duplicate", "#6b7280", "Duplicate tickets") - ); - - Map data = new LinkedHashMap<>(); - data.put("labels", labels); - - settingsRepository.saveEntity(server, newSettingsDocument("ticketLabels", data)); - } - - private Map labelMap(String name, String color, String description) { - Map label = new LinkedHashMap<>(); - label.put("id", UUID.randomUUID().toString()); - label.put("name", name); - label.put("color", color); - label.put("description", description); - return label; - } - - private List seedKnowledgebaseCategories(Server server) { - if (knowledgebaseCategoryRepository.hasAny(server)) { - return List.of(); - } - - Date now = new Date(); - List categories = List.of( - KnowledgebaseCategory.builder() - .name("Rules & Policies") - .slug("rules-policies") - .description("Server rules, community guidelines, and policies") - .ordinal(0) - .isVisible(true) - .createdAt(now) - .updatedAt(now) - .build(), - KnowledgebaseCategory.builder() - .name("Guides & Troubleshooting") - .slug("guides-troubleshooting") - .description("How-to guides and troubleshooting help") - .ordinal(1) - .isVisible(true) - .createdAt(now) - .updatedAt(now) - .build(), - KnowledgebaseCategory.builder() - .name("News & Updates") - .slug("news-updates") - .description("Latest announcements, updates, and news") - .ordinal(2) - .isVisible(true) - .createdAt(now) - .updatedAt(now) - .build() - ); - - for (KnowledgebaseCategory category : categories) { - knowledgebaseCategoryRepository.saveEntity(server, category); - } - return categories; - } - - private void seedHomepageCards(Server server) { - if (homepageCardRepository.hasAny(server)) { - return; - } - - List categories = knowledgebaseCategoryRepository.findAllOrdered(server); - - // Find category IDs for category_dropdown cards - String rulesCategoryId = categories.stream() - .filter(c -> "rules-policies".equals(c.getSlug())) - .findFirst().map(KnowledgebaseCategory::getId).orElse(null); - String guidesCategoryId = categories.stream() - .filter(c -> "guides-troubleshooting".equals(c.getSlug())) - .findFirst().map(KnowledgebaseCategory::getId).orElse(null); - String newsCategoryId = categories.stream() - .filter(c -> "news-updates".equals(c.getSlug())) - .findFirst().map(KnowledgebaseCategory::getId).orElse(null); - - Date now = new Date(); - - List cards = List.of( - HomepageCard.builder() - .title("Appeal Punishment") - .description("Submit an appeal if you believe you were unfairly banned or punished") - .icon("Scale") - .iconColor("#f59e0b") - .actionType("url") - .actionUrl("/appeal") - .actionButtonText("Submit Appeal") - .isEnabled(true) - .ordinal(0) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Apply for Staff") - .description("Join our staff team and help manage the community") - .icon("UserPlus") - .iconColor("#3b82f6") - .actionType("url") - .actionUrl("/submit-ticket/apply") - .actionButtonText("Apply Now") - .isEnabled(true) - .ordinal(1) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Contact Us") - .description("Get help from our support team for any issues") - .icon("MessageCircle") - .iconColor("#10b981") - .actionType("url") - .actionUrl("/submit-ticket/support") - .actionButtonText("Contact Support") - .isEnabled(true) - .ordinal(2) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Rules & Policies") - .description("Browse server rules, community guidelines, and policies") - .icon("BookOpen") - .iconColor("#8b5cf6") - .actionType("category_dropdown") - .categoryId(rulesCategoryId) - .isEnabled(true) - .ordinal(3) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("Guides & Troubleshooting") - .description("Find helpful guides and troubleshooting resources") - .icon("HelpCircle") - .iconColor("#f97316") - .actionType("category_dropdown") - .categoryId(guidesCategoryId) - .isEnabled(true) - .ordinal(4) - .createdAt(now) - .updatedAt(now) - .build(), - HomepageCard.builder() - .title("News & Updates") - .description("Stay up to date with the latest announcements and changes") - .icon("Newspaper") - .iconColor("#6366f1") - .actionType("category_dropdown") - .categoryId(newsCategoryId) - .isEnabled(true) - .ordinal(5) - .createdAt(now) - .updatedAt(now) - .build() - ); - - for (HomepageCard card : cards) { - homepageCardRepository.saveEntity(server, card); - } - } - -} +package gg.modl.backend.server.service; + +import gg.modl.backend.database.MongoIndexBootstrapService; +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; +import gg.modl.backend.database.mongo.repository.KnowledgebaseCategoryMongoRepository; +import gg.modl.backend.database.mongo.repository.SettingsMongoRepository; +import gg.modl.backend.homepage.data.HomepageCard; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; +import gg.modl.backend.role.service.RoleService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.Settings; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ServerProvisioningService { + private static final String AI_CHAT_ABUSE_CONFIG_ID = "6"; + private static final String AI_ANTI_SOCIAL_CONFIG_ID = "7"; + + private final TenantMongoAccess tenantMongoAccess; + private final MongoIndexBootstrapService mongoIndexBootstrapService; + private final SettingsMongoRepository settingsRepository; + private final KnowledgebaseCategoryMongoRepository knowledgebaseCategoryRepository; + private final HomepageCardMongoRepository homepageCardRepository; + private final RoleService roleService; + + public void provision(Server server) { + List failures = new ArrayList<>(); + Exception firstError = null; + + for (Map.Entry step : provisioningSteps(server).entrySet()) { + try { + step.getValue().run(); + } catch (Exception e) { + log.error("[Provisioning] Step '{}' failed for server: {}", step.getKey(), server.getCustomDomain(), e); + failures.add(step.getKey() + ": " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage())); + if (firstError == null) { + firstError = e; + } + } + } + + if (!failures.isEmpty()) { + throw new ProvisioningException( + "Provisioning failed for " + server.getCustomDomain() + " [" + String.join("; ", failures) + "]", + firstError); + } + } + + private Map provisioningSteps(Server server) { + Map steps = new LinkedHashMap<>(); + steps.put("createTenantIndexes", () -> mongoIndexBootstrapService.createTenantIndexes(tenantMongoAccess.forServer(server))); + steps.put("seedAIModerationSettings", () -> seedAIModerationSettings(server)); + steps.put("seedTicketForms", () -> seedTicketForms(server)); + steps.put("seedQuickResponses", () -> seedQuickResponses(server)); + steps.put("seedGeneralSettings", () -> seedGeneralSettings(server)); + steps.put("seedTicketLabelSettings", () -> seedTicketLabelSettings(server)); + steps.put("seedKnowledgebaseCategories", () -> seedKnowledgebaseCategories(server)); + steps.put("seedHomepageCards", () -> seedHomepageCards(server)); + steps.put("createDefaultRoles", () -> roleService.createDefaultRoles(server)); + return steps; + } + + private void seedAIModerationSettings(Server server) { + if (settingsExist(server, "aiModerationSettings")) { + return; + } + + Map chatAbuseConfig = new LinkedHashMap<>(); + chatAbuseConfig.put("id", AI_CHAT_ABUSE_CONFIG_ID); + chatAbuseConfig.put("name", "Chat Abuse"); + chatAbuseConfig.put("aiDescription", + "Chat abuse is the act of spamming, excessive profanity, abusive language, inappropriate topics or jokes, and misleading information"); + chatAbuseConfig.put("enabled", true); + + Map antiSocialConfig = new LinkedHashMap<>(); + antiSocialConfig.put("id", AI_ANTI_SOCIAL_CONFIG_ID); + antiSocialConfig.put("name", "Anti Social"); + antiSocialConfig.put("aiDescription", + "Anti social is the act of harassing, threatening, black-mailing, or otherwise abusing another player or group of players. This includes bigotry and other forms of discrimination against protected classes."); + antiSocialConfig.put("enabled", true); + + Map aiPunishmentConfigs = new LinkedHashMap<>(); + aiPunishmentConfigs.put(AI_CHAT_ABUSE_CONFIG_ID, chatAbuseConfig); + aiPunishmentConfigs.put(AI_ANTI_SOCIAL_CONFIG_ID, antiSocialConfig); + + Map data = new LinkedHashMap<>(); + data.put("enableAIReview", false); + data.put("enableAutomatedActions", false); + data.put("aiPunishmentConfigs", aiPunishmentConfigs); + + settingsRepository.saveEntity(server, newSettingsDocument("aiModerationSettings", data)); + } + + private boolean settingsExist(Server server, String type) { + return settingsRepository.existsByType(server, type); + } + + private Settings newSettingsDocument(String type, Map data) { + return new Settings(null, type, data, 0L, new Date()); + } + + private void seedTicketForms(Server server) { + if (settingsExist(server, "ticketForms")) { + return; + } + + // Bug report form + Map bugForm = new LinkedHashMap<>(); + bugForm.put("fields", List.of( + formField("1753243804677", "textarea", "Bug Description", "Describe the bug in full detail", true, 3, "1753243782799"), + formField("1753243846548", "textarea", "Environment", "Game/server, client version, and any other relevant conditions", true, 3, "1753243782799"), + formField("1753243865490", "textarea", "Steps to reproduce", "Detailed description on how we can reproduce the bug", true, 2, "1753243782799"), + formField("1753243883567", "textarea", "Any other information?", null, false, 3, "1753243782799"), + formField("1753243946458", "file_upload", "Attachments", "Upload relevant attachments to help us squash this bug.", false, 4, "1753243782799") + )); + bugForm.put("sections", List.of( + formSection("1753243782799", "General", 0, false) + )); + + // Support form + Map supportForm = new LinkedHashMap<>(); + supportForm.put("fields", List.of( + formField("1753243961223", "textarea", "Description", "How can we assist you?", true, 0, "1753243900648"), + formField("1753243997358", "file_upload", "Attachments", "Upload any relevant attachments.", false, 1, "1753243900648") + )); + supportForm.put("sections", List.of( + formSection("1753243900648", "General", 0, false) + )); + + // Application form + Map applicationForm = buildApplicationForm(); + + Map data = new LinkedHashMap<>(); + data.put("bug", bugForm); + data.put("support", supportForm); + data.put("application", applicationForm); + + settingsRepository.saveEntity(server, newSettingsDocument("ticketForms", data)); + } + + private Map buildApplicationForm() { + List> fields = new ArrayList<>(); + + // General section fields + fields.add(formField("1753244313811", "text", "First Name", null, true, 0, "1753244011186")); + fields.add(formField("1753244038340", "text", "Discord username", "Please use the new username format, starting with an @.", true, 1, "1753244011186")); + fields.add(formField("1753244070995", "text", "Age", null, true, 2, "1753244011186")); + fields.add(formField("1753244166086", "text", "Region & Timezone", "Ex: NA, Eastern Time", true, 3, "1753244011186")); + fields.add( + formField("1753244525756", "text", "What languages can you speak?", "If you speak more than one, please list your level of fluency in each.", true, + 4, "1753244011186")); + fields.add( + formField("1753244114967", "checkbox", "Do you have access to both a working microphone and recording software?", null, true, 5, "1753244011186")); + + // Position dropdown with section mapping + Map positionField = new LinkedHashMap<>(); + positionField.put("id", "1753244244863"); + positionField.put("type", "dropdown"); + positionField.put("label", "Position"); + positionField.put("description", "What position are you applying for?"); + positionField.put("required", true); + positionField.put("options", List.of("Moderator", "Builder", "Developer", "Media")); + positionField.put("order", 6); + positionField.put("sectionId", "1753244011186"); + positionField.put("optionSectionMapping", Map.of( + "Moderator", "1753244183109", + "Builder", "1753244277605", + "Developer", "1753244282540", + "Media", "1753244286527" + )); + fields.add(positionField); + + // Moderator section fields + fields.add(formField("1753244506417", "textarea", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", + "If so, please explain each occurrence.", true, 0, "1753244183109")); + fields.add(formField("1753244551193", "textarea", "Describe your moderation background and previous experience.", + "The more detail the better. This doesn't have to be limited to Minecraft servers, as we welcome any previous experience in moderating Discord servers or even other game communities. Please provide references and proof for your more notable experiences.", + true, 1, "1753244183109")); + fields.add(formField("1753244585381", "textarea", "Why do you want to become a moderator on this server?", + "Again, the more detail on this question the better. Providing us with as much detail as possible will help us understand your motivation and will to become a moderator!", + true, 9, "1753244183109")); + fields.add(formField("1753244603377", "text", "How much time do you see yourself committing to the server?", null, true, 10, "1753244183109")); + fields.add(formField("1753244687326", "textarea", + "You are a Moderator with the ability to mute and ban. You are playing on the server with a friend and come across a player who you think is hacking. They kill your friend, but then you kill them. What do you do in this situation?", + null, true, 11, "1753244183109")); + fields.add(formField("1753244762984", "textarea", + "You are a Moderator with the ability to mute and ban. You are spectating a player who you believe is hacking, but multiple chat reports come in about a player in another gamemode who is being violently disruptive in chat. Somehow, you are the only moderator online. How do you handle the two situations?", + null, true, 12, "1753244183109")); + fields.add(formField("1753244861431", "textarea", + "You are a Moderator with the ability to mute and ban. You see 5+ reports come in accusing the same player of breaking the chat rules. You join the server where the situation is taking place and open the accused player's recent chat history. You see that they were being rude, but haven't actually broken a rule. When you decide that they are not guilty, the same group reports the player again, and sends you multiple private messages calling you a bad moderator for not muting the player. What's the first step in dealing with this situation? Explain how this step will move towards resolving the conflict.", + null, true, 13, "1753244183109")); + fields.add(formField("1753244931272", "textarea", + "You are the newest Moderator on the team. While you are spectating a game, you witness a Sr. Moderator mining suspiciously. In a matter of minutes, you get enough evidence that suggests that the Sr. Moderator may likely be x-raying. Suddenly, they head to the surface and do nothing suspicious for the rest of your time spectating them. How do you proceed?", + null, true, 14, "1753244183109")); + fields.add(formField("1753245023983", "textarea", + "You are a Moderator with the ability to mute and ban. You notice a well-known streamer/YouTuber closely affiliated with the server is nicked. They message a player words encouraging suicide under their disguised alias. What steps do you take to resolve the situation?", + null, true, 15, "1753244183109")); + + // Builder section fields + fields.add(formField("1753245081481", "textarea", "Do you have experience building for other servers?", null, true, 16, "1753244277605")); + fields.add(formField("1753245137086", "textarea", "Please provide proof of previous work in link form here (Imgur, YouTube, etc)", null, true, 17, + "1753244277605")); + fields.add(formField("1753245154307", "textarea", "Anything else you would like to say?", null, false, 23, "1753244277605")); + + // Developer section fields + fields.add(formField("1753245191475", "textarea", "Why do you want to be a developer on this server?", null, true, 0, "1753244282540")); + fields.add(formField("1753245262717", "textarea", "Do you have experience developing for other servers?", null, true, 1, "1753244282540")); + fields.add(formField("1753245280773", "text", "Please provide proof of previous work in the form of a GitHub link", null, true, 2, "1753244282540")); + fields.add(formField("1753245291714", "textarea", "Anything else you would like to say?", null, false, 3, "1753244282540")); + + // Media section fields + fields.add(formField("1753245348514", "text", "Have you ever been banned or muted on this server? If yes, what have you learned moving forward?", + "If so, please explain each occurrence.", true, 23, "1753244286527")); + fields.add(formField("1753245358313", "text", "A link to your YouTube and/or Stream Channel", null, true, 24, "1753244286527")); + fields.add(formField("1753245471763", "checkbox", + "We will email the contact email listed on the channel for proof of ownership, please verify it is accurate and actively monitored.", null, true, + 25, "1753244286527")); + fields.add(formField("1753245511672", "textarea", "Anything else you would like to say?", null, false, 26, "1753244286527")); + + List> sections = List.of( + formSection("1753244011186", "General", 0, false), + formSection("1753244183109", "Moderator", 1, true), + formSection("1753244277605", "Builder", 2, true), + formSection("1753244282540", "Developer", 3, true), + formSection("1753244286527", "Media", 4, true) + ); + + Map form = new LinkedHashMap<>(); + form.put("fields", fields); + form.put("sections", sections); + return form; + } + + private Map formField(String id, String type, String label, String description, boolean required, int order, String sectionId) { + Map field = new LinkedHashMap<>(); + field.put("id", id); + field.put("type", type); + field.put("label", label); + if (description != null) { + field.put("description", description); + } + field.put("required", required); + field.put("order", order); + field.put("sectionId", sectionId); + return field; + } + + private Map formSection(String id, String title, int order, boolean hideByDefault) { + Map section = new LinkedHashMap<>(); + section.put("id", id); + section.put("title", title); + section.put("order", order); + section.put("hideByDefault", hideByDefault); + return section; + } + + private void seedQuickResponses(Server server) { + if (settingsExist(server, "quickResponses")) { + return; + } + + List> categories = new ArrayList<>(); + + // Chat Report Actions + categories.add(quickResponseCategory("chat_report_actions", "Chat Report Actions", List.of("chat_report"), 1, List.of( + quickResponseAction("accept_report", "Accept Report", + "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, + true, true, null), + quickResponseAction("reject_insufficient_chat", "More Evidence", + "Thank you for submitting this chat report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, + false, false, null), + quickResponseAction("reject_no_violation_chat", "Reject - No Violation", + "Thank you for submitting this chat report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", + 3, true, false, null) + ))); + + // Player Report Actions + categories.add(quickResponseCategory("player_report_actions", "Player Report Actions", List.of("player_report"), 2, List.of( + quickResponseAction("accept_report", "Accept Report", + "Thank you for creating this report. After careful review, we have accepted this and the reported player will be receiving a punishment.", 1, + true, true, null), + quickResponseAction("reject_insufficient_player", "More Evidence", + "Thank you for submitting this player report. After reviewing the evidence provided, we need additional evidence to proceed with action.", 2, + false, false, null), + quickResponseAction("reject_no_violation_player", "Reject - No Violation", + "Thank you for submitting this player report. After reviewing the evidence provided, we have determined that this does not violate our community guidelines.", + 3, true, false, null) + ))); + + // Appeal Actions + categories.add(quickResponseCategory("appeal_actions", "Appeal Actions", List.of("appeal"), 3, List.of( + quickResponseAction("pardon_full", "Pardon - Full", + "After reviewing your appeal, we have decided to remove the punishment completely. We apologize for any inconvenience.", 1, true, false, + "pardon"), + quickResponseAction("reduce_punishment", "Reduce Punishment", + "We have reviewed your appeal and decided to reduce the duration of your punishment. Please check your punishment details for the updated duration.", + 2, true, false, "reduce"), + quickResponseAction("reject_upheld", "Reject - Upheld", + "After careful consideration of your appeal, we have decided to uphold the original punishment.", 3, true, false, "reject"), + quickResponseAction("need_more_info_appeal", "Need More Information", + "We need additional information to process your appeal. Please provide more details about your situation.", 4, false, false, null) + ))); + + // Staff Application Actions + categories.add(quickResponseCategory("application_actions", "Staff Application Actions", List.of("application"), 4, List.of( + quickResponseAction("accept_builder", "Accept - Builder", + "Congratulations! Your Builder application has been accepted. Welcome to the Builder team! You will receive further instructions and permissions shortly.", + 1, true, false, null), + quickResponseAction("accept_helper", "Accept - Helper", + "Congratulations! Your Helper application has been accepted. Welcome to the Helper team! You will receive further instructions and permissions shortly.", + 2, true, false, null), + quickResponseAction("accept_developer", "Accept - Developer", + "Congratulations! Your Developer application has been accepted. Welcome to the Developer team! You will receive further instructions and permissions shortly.", + 3, true, false, null), + quickResponseAction("reject_application", "Reject Application", + "Thank you for your interest in joining our team. Unfortunately, we have decided not to move forward with your application at this time. You may reapply in the future.", + 4, true, false, null), + quickResponseAction("pending_review", "Pending Review", + "Thank you for your application. We are currently reviewing it and will get back to you soon.", 5, false, false, null), + quickResponseAction("interview_scheduled", "Interview Scheduled", + "Your application has progressed to the interview stage. Please check your email for interview details.", 6, false, false, null), + quickResponseAction("need_more_info_app", "Need More Information", + "We need additional information about your application. Please provide more details about your experience and qualifications.", 7, false, false, + null) + ))); + + // Bug Report Actions + categories.add(quickResponseCategory("bug_actions", "Bug Report Actions", List.of("bug"), 5, List.of( + quickResponseAction("completed", "Fixed", "Thank you for reporting this bug. We have fixed the issue and it will be included in our next update.", + 1, true, false, null), + quickResponseAction("investigating", "Investigating", + "Thank you for this bug report. We are currently investigating the issue and will provide updates as they become available.", 2, false, false, + null), + quickResponseAction("need_more_info", "Need More Info", + "Thank you for this bug report. We need additional information to investigate this issue. Please provide more details about how to reproduce this bug.", + 3, false, false, null), + quickResponseAction("duplicate", "Duplicate", + "This bug has been identified as a duplicate of an existing issue. We appreciate your report and are working on a fix.", 4, true, false, null), + quickResponseAction("cannot_reproduce", "Cannot Reproduce", + "We were unable to reproduce this issue. If you continue to experience this problem, please provide additional details.", 5, true, false, null) + ))); + + // Support Actions + categories.add(quickResponseCategory("support_actions", "Support Actions", List.of("support"), 6, List.of( + quickResponseAction("resolved", "Resolved", + "Your support request has been resolved. If you need further assistance, please feel free to create a new ticket.", 1, true, false, null), + quickResponseAction("escalated", "Escalated", + "Your support request has been escalated to our specialized team. They will contact you with additional information.", 2, false, false, null), + quickResponseAction("need_info_support", "Need More Info", + "We need additional information to assist you with your request. Please provide more details about your issue.", 3, false, false, null) + ))); + + // General Actions + categories.add( + quickResponseCategory("general_actions", "General Actions", List.of("player_report", "chat_report", "bug", "appeal", "support", "application"), 7, + List.of( + quickResponseAction("acknowledge", "Acknowledge", "Thank you for your message. We have received your ticket and will review it shortly.", 1, + false, false, null), + quickResponseAction("follow_up", "Follow Up", + "We are following up on your ticket. Please let us know if you have any additional information or questions.", 2, false, false, null) + ))); + + Map data = new LinkedHashMap<>(); + data.put("categories", categories); + + settingsRepository.saveEntity(server, newSettingsDocument("quickResponses", data)); + } + + private Map quickResponseCategory(String id, String name, List ticketTypes, int order, List> actions) { + Map category = new LinkedHashMap<>(); + category.put("id", id); + category.put("name", name); + category.put("ticketTypes", ticketTypes); + category.put("order", order); + category.put("actions", actions); + return category; + } + + private Map quickResponseAction(String id, String name, String message, int order, boolean closeTicket, boolean showPunishment, String appealAction) { + Map action = new LinkedHashMap<>(); + action.put("id", id); + action.put("name", name); + action.put("message", message); + action.put("order", order); + action.put("closeTicket", closeTicket); + if (showPunishment) { + action.put("showPunishment", true); + } + if (appealAction != null) { + action.put("appealAction", appealAction); + } + return action; + } + + // Helper methods for building form data structures + + private void seedGeneralSettings(Server server) { + if (settingsExist(server, "general")) { + return; + } + + Map data = new LinkedHashMap<>(); + data.put("serverDisplayName", ""); + data.put("discordWebhookUrl", ""); + data.put("homepageIconUrl", ""); + data.put("panelIconUrl", ""); + + settingsRepository.saveEntity(server, newSettingsDocument("general", data)); + } + + private void seedTicketLabelSettings(Server server) { + if (settingsExist(server, "ticketLabels")) { + return; + } + + List> labels = List.of( + labelMap("high priority", "#e74c3c", "High priority tickets"), + labelMap("needs admin review", "#f39c12", "Tickets that need review"), + labelMap("in progress", "#2ecc71", "Tickets being worked on"), + labelMap("won't fix", "#6b7280", "Issues that won't be fixed"), + labelMap("duplicate", "#6b7280", "Duplicate tickets") + ); + + Map data = new LinkedHashMap<>(); + data.put("labels", labels); + + settingsRepository.saveEntity(server, newSettingsDocument("ticketLabels", data)); + } + + private Map labelMap(String name, String color, String description) { + Map label = new LinkedHashMap<>(); + label.put("id", UUID.randomUUID().toString()); + label.put("name", name); + label.put("color", color); + label.put("description", description); + return label; + } + + private List seedKnowledgebaseCategories(Server server) { + if (knowledgebaseCategoryRepository.hasAny(server)) { + return List.of(); + } + + Date now = new Date(); + List categories = List.of( + KnowledgebaseCategory.builder() + .name("Rules & Policies") + .slug("rules-policies") + .description("Server rules, community guidelines, and policies") + .ordinal(0) + .isVisible(true) + .createdAt(now) + .updatedAt(now) + .build(), + KnowledgebaseCategory.builder() + .name("Guides & Troubleshooting") + .slug("guides-troubleshooting") + .description("How-to guides and troubleshooting help") + .ordinal(1) + .isVisible(true) + .createdAt(now) + .updatedAt(now) + .build(), + KnowledgebaseCategory.builder() + .name("News & Updates") + .slug("news-updates") + .description("Latest announcements, updates, and news") + .ordinal(2) + .isVisible(true) + .createdAt(now) + .updatedAt(now) + .build() + ); + + for (KnowledgebaseCategory category : categories) { + knowledgebaseCategoryRepository.saveEntity(server, category); + } + return categories; + } + + private void seedHomepageCards(Server server) { + if (homepageCardRepository.hasAny(server)) { + return; + } + + List categories = knowledgebaseCategoryRepository.findAllOrdered(server); + + // Find category IDs for category_dropdown cards + String rulesCategoryId = categories.stream() + .filter(c -> "rules-policies".equals(c.getSlug())) + .findFirst().map(KnowledgebaseCategory::getId).orElse(null); + String guidesCategoryId = categories.stream() + .filter(c -> "guides-troubleshooting".equals(c.getSlug())) + .findFirst().map(KnowledgebaseCategory::getId).orElse(null); + String newsCategoryId = categories.stream() + .filter(c -> "news-updates".equals(c.getSlug())) + .findFirst().map(KnowledgebaseCategory::getId).orElse(null); + + Date now = new Date(); + + List cards = List.of( + HomepageCard.builder() + .title("Appeal Punishment") + .description("Submit an appeal if you believe you were unfairly banned or punished") + .icon("Scale") + .iconColor("#f59e0b") + .actionType("url") + .actionUrl("/appeal") + .actionButtonText("Submit Appeal") + .isEnabled(true) + .ordinal(0) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Apply for Staff") + .description("Join our staff team and help manage the community") + .icon("UserPlus") + .iconColor("#3b82f6") + .actionType("url") + .actionUrl("/submit-ticket/apply") + .actionButtonText("Apply Now") + .isEnabled(true) + .ordinal(1) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Contact Us") + .description("Get help from our support team for any issues") + .icon("MessageCircle") + .iconColor("#10b981") + .actionType("url") + .actionUrl("/submit-ticket/support") + .actionButtonText("Contact Support") + .isEnabled(true) + .ordinal(2) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Rules & Policies") + .description("Browse server rules, community guidelines, and policies") + .icon("BookOpen") + .iconColor("#8b5cf6") + .actionType("category_dropdown") + .categoryId(rulesCategoryId) + .isEnabled(true) + .ordinal(3) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("Guides & Troubleshooting") + .description("Find helpful guides and troubleshooting resources") + .icon("HelpCircle") + .iconColor("#f97316") + .actionType("category_dropdown") + .categoryId(guidesCategoryId) + .isEnabled(true) + .ordinal(4) + .createdAt(now) + .updatedAt(now) + .build(), + HomepageCard.builder() + .title("News & Updates") + .description("Stay up to date with the latest announcements and changes") + .icon("Newspaper") + .iconColor("#6366f1") + .actionType("category_dropdown") + .categoryId(newsCategoryId) + .isEnabled(true) + .ordinal(5) + .createdAt(now) + .updatedAt(now) + .build() + ); + + for (HomepageCard card : cards) { + homepageCardRepository.saveEntity(server, card); + } + } + +} diff --git a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java index d2aca2b..bac30ef 100644 --- a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java +++ b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeController.java @@ -1,33 +1,33 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -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; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) -@RequiredArgsConstructor -public class MinecraftPunishmentTypeController { - private final PunishmentTypeService punishmentTypeService; - - @GetMapping("/types") - public ResponseEntity> getPunishmentTypes(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List types = punishmentTypeService.getPunishmentTypes(server); - - return ResponseEntity.ok(Map.of( - "status", 200, - "data", types - )); - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_PUNISHMENTS) +@RequiredArgsConstructor +public class MinecraftPunishmentTypeController { + private final PunishmentTypeService punishmentTypeService; + + @GetMapping("/types") + public ResponseEntity> getPunishmentTypes(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List types = punishmentTypeService.getPunishmentTypes(server); + + return ResponseEntity.ok(Map.of( + "status", 200, + "data", types + )); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java b/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java index 43b5d38..e28ec1c 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java @@ -1,98 +1,98 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.DomainSettings; -import gg.modl.backend.settings.service.CustomDomainAccessService; -import gg.modl.backend.settings.service.DomainSettingsService; -import gg.modl.proto.modl.v1.ConfigureDomainRequest; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.RemoveDomainResponse; -import gg.modl.proto.modl.v1.VerifyDomainRequest; -import gg.modl.proto.modl.v1.VerifyDomainResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/domain") -@RequiredArgsConstructor -public class PanelDomainSettingsController { - private final DomainSettingsService domainSettingsService; - private final CustomDomainAccessService customDomainAccessService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping - public gg.modl.proto.modl.v1.DomainSettings getDomainSettings(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - String host = request.getHeader("Host"); - DomainSettings settings = domainSettingsService.getDomainSettings(server, host); - return PanelSettingsProtoMapper.toDomainSettings(settings); - } - - @PostMapping - public gg.modl.proto.modl.v1.DomainSettings configureDomain( - @RequestBody ConfigureDomainRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireCustomDomainWriteAccess(server); - - DomainSettings settings = domainSettingsService.configureDomain(server, body.getCustomDomain().trim()); - invalidateSettings(server); - return PanelSettingsProtoMapper.toDomainSettings(settings); - } - - private void requireCustomDomainWriteAccess(Server server) { - if (!customDomainAccessService.canManageCustomDomain(server)) { - throw new ForbiddenException("Custom domains require Premium unless your server is grandfathered."); - } - } - - @PostMapping("/verify") - public VerifyDomainResponse verifyDomain( - @RequestBody VerifyDomainRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireCustomDomainWriteAccess(server); - - DomainSettings settings = domainSettingsService.verifyDomain(server, body.getDomain().trim()); - DomainSettings.DomainStatus status = settings.getStatus(); - - String message = switch (status.getStatus()) { - case "active" -> status.getSslStatus().equals("active") - ? "Domain verified successfully with active SSL!" - : "Domain verified! SSL certificate is being provisioned."; - case "error" -> status.getError() != null - ? status.getError() - : "Domain verification failed"; - default -> "Domain verification pending. Please ensure your CNAME is configured correctly."; - }; - - invalidateSettings(server); - return PanelSettingsProtoMapper.toVerifyDomainResponse(settings, message); - } - - @DeleteMapping - public RemoveDomainResponse removeDomain(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - requireCustomDomainWriteAccess(server); - - domainSettingsService.removeDomain(server); - invalidateSettings(server); - return PanelSettingsProtoMapper.toRemoveDomainResponse("Domain removed successfully"); - } - - private void invalidateSettings(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.DomainSettings; +import gg.modl.backend.settings.service.CustomDomainAccessService; +import gg.modl.backend.settings.service.DomainSettingsService; +import gg.modl.proto.modl.v1.ConfigureDomainRequest; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.RemoveDomainResponse; +import gg.modl.proto.modl.v1.VerifyDomainRequest; +import gg.modl.proto.modl.v1.VerifyDomainResponse; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/domain") +@RequiredArgsConstructor +public class PanelDomainSettingsController { + private final DomainSettingsService domainSettingsService; + private final CustomDomainAccessService customDomainAccessService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping + public gg.modl.proto.modl.v1.DomainSettings getDomainSettings(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + String host = request.getHeader("Host"); + DomainSettings settings = domainSettingsService.getDomainSettings(server, host); + return PanelSettingsProtoMapper.toDomainSettings(settings); + } + + @PostMapping + public gg.modl.proto.modl.v1.DomainSettings configureDomain( + @RequestBody ConfigureDomainRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireCustomDomainWriteAccess(server); + + DomainSettings settings = domainSettingsService.configureDomain(server, body.getCustomDomain().trim()); + invalidateSettings(server); + return PanelSettingsProtoMapper.toDomainSettings(settings); + } + + private void requireCustomDomainWriteAccess(Server server) { + if (!customDomainAccessService.canManageCustomDomain(server)) { + throw new ForbiddenException("Custom domains require Premium unless your server is grandfathered."); + } + } + + @PostMapping("/verify") + public VerifyDomainResponse verifyDomain( + @RequestBody VerifyDomainRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireCustomDomainWriteAccess(server); + + DomainSettings settings = domainSettingsService.verifyDomain(server, body.getDomain().trim()); + DomainSettings.DomainStatus status = settings.getStatus(); + + String message = switch (status.getStatus()) { + case "active" -> status.getSslStatus().equals("active") + ? "Domain verified successfully with active SSL!" + : "Domain verified! SSL certificate is being provisioned."; + case "error" -> status.getError() != null + ? status.getError() + : "Domain verification failed"; + default -> "Domain verification pending. Please ensure your CNAME is configured correctly."; + }; + + invalidateSettings(server); + return PanelSettingsProtoMapper.toVerifyDomainResponse(settings, message); + } + + @DeleteMapping + public RemoveDomainResponse removeDomain(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + requireCustomDomainWriteAccess(server); + + domainSettingsService.removeDomain(server); + invalidateSettings(server); + return PanelSettingsProtoMapper.toRemoveDomainResponse("Domain removed successfully"); + } + + private void invalidateSettings(Server server) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java b/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java index 719f916..5610efe 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java @@ -1,137 +1,137 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.proto.modl.v1.PanelPunishmentTypesResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PunishmentTypeRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/punishment-types") -@RequiredArgsConstructor -public class PanelPunishmentTypeController { - private final PunishmentTypeService punishmentTypeService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PermissionService permissionService; - private final Validator validator; - - @GetMapping - public PanelPunishmentTypesResponse getPunishmentTypes(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List types = punishmentTypeService.getPunishmentTypes(server); - return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); - } - - @GetMapping("/{ordinal}") - public ResponseEntity getPunishmentType( - @PathVariable int ordinal, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) - .map(PanelSettingsProtoMapper::toPunishmentType) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - @PatchMapping("/{ordinal}") - public gg.modl.proto.modl.v1.PunishmentType updatePunishmentType( - @PathVariable int ordinal, - @RequestBody PunishmentTypeRequest requestBody, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PunishmentType updatedType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); - validate(updatedType); - - String previousName = punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) - .map(PunishmentType::getName) - .orElse(null); - - PunishmentType result = punishmentTypeService.updatePunishmentType(server, ordinal, updatedType); - - if (previousName != null && !previousName.equals(result.getName())) { - permissionService.renamePunishmentApplyPermission(server, previousName, result.getName()); - } - - invalidatePunishmentTypes(server, ordinal); - return PanelSettingsProtoMapper.toPunishmentType(result); - } - - @PostMapping - public gg.modl.proto.modl.v1.PunishmentType createPunishmentType( - @RequestBody PunishmentTypeRequest requestBody, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PunishmentType newType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); - validate(newType); - PunishmentType created = punishmentTypeService.createPunishmentType(server, newType); - invalidatePunishmentTypes(server, created.getOrdinal()); - return PanelSettingsProtoMapper.toPunishmentType(created); - } - - @PostMapping("/reset") - public PanelPunishmentTypesResponse resetPunishmentTypes(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List types = punishmentTypeService.initializeDefaultTypes(server); - invalidatePunishmentTypes(server, null); - return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); - } - - @DeleteMapping("/{ordinal}") - public ResponseEntity deletePunishmentType( - @PathVariable int ordinal, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (ordinal < 6) { - throw new ValidationException("Cannot delete core administrative punishment types"); - } - - boolean deleted = punishmentTypeService.deletePunishmentType(server, ordinal); - if (!deleted) { - return ResponseEntity.notFound().build(); - } - invalidatePunishmentTypes(server, ordinal); - return ResponseEntity.ok(Map.of("message", "Punishment type deleted successfully")); - } - - private void invalidatePunishmentTypes(Server server, Integer ordinal) { - realtimeEventPublisher.invalidatePanel( - server, - PanelResource.PANEL_RESOURCE_PUNISHMENT_TYPES, - ordinal != null ? String.valueOf(ordinal) : null - ); - } - - private void validate(T target) { - Set> violations = validator.validate(target); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.proto.modl.v1.PanelPunishmentTypesResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PunishmentTypeRequest; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/punishment-types") +@RequiredArgsConstructor +public class PanelPunishmentTypeController { + private final PunishmentTypeService punishmentTypeService; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PermissionService permissionService; + private final Validator validator; + + @GetMapping + public PanelPunishmentTypesResponse getPunishmentTypes(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List types = punishmentTypeService.getPunishmentTypes(server); + return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); + } + + @GetMapping("/{ordinal}") + public ResponseEntity getPunishmentType( + @PathVariable int ordinal, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) + .map(PanelSettingsProtoMapper::toPunishmentType) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PatchMapping("/{ordinal}") + public gg.modl.proto.modl.v1.PunishmentType updatePunishmentType( + @PathVariable int ordinal, + @RequestBody PunishmentTypeRequest requestBody, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PunishmentType updatedType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); + validate(updatedType); + + String previousName = punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) + .map(PunishmentType::getName) + .orElse(null); + + PunishmentType result = punishmentTypeService.updatePunishmentType(server, ordinal, updatedType); + + if (previousName != null && !previousName.equals(result.getName())) { + permissionService.renamePunishmentApplyPermission(server, previousName, result.getName()); + } + + invalidatePunishmentTypes(server, ordinal); + return PanelSettingsProtoMapper.toPunishmentType(result); + } + + @PostMapping + public gg.modl.proto.modl.v1.PunishmentType createPunishmentType( + @RequestBody PunishmentTypeRequest requestBody, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PunishmentType newType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); + validate(newType); + PunishmentType created = punishmentTypeService.createPunishmentType(server, newType); + invalidatePunishmentTypes(server, created.getOrdinal()); + return PanelSettingsProtoMapper.toPunishmentType(created); + } + + @PostMapping("/reset") + public PanelPunishmentTypesResponse resetPunishmentTypes(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List types = punishmentTypeService.initializeDefaultTypes(server); + invalidatePunishmentTypes(server, null); + return PanelSettingsProtoMapper.toPunishmentTypesResponse(types); + } + + @DeleteMapping("/{ordinal}") + public ResponseEntity deletePunishmentType( + @PathVariable int ordinal, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + if (ordinal < 6) { + throw new ValidationException("Cannot delete core administrative punishment types"); + } + + boolean deleted = punishmentTypeService.deletePunishmentType(server, ordinal); + if (!deleted) { + return ResponseEntity.notFound().build(); + } + invalidatePunishmentTypes(server, ordinal); + return ResponseEntity.ok(Map.of("message", "Punishment type deleted successfully")); + } + + private void invalidatePunishmentTypes(Server server, Integer ordinal) { + realtimeEventPublisher.invalidatePanel( + server, + PanelResource.PANEL_RESOURCE_PUNISHMENT_TYPES, + ordinal != null ? String.valueOf(ordinal) : null + ); + } + + private void validate(T target) { + Set> violations = validator.validate(target); + if (!violations.isEmpty()) { + throw new ValidationException(violations.iterator().next().getMessage()); + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java b/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java index d1cff29..30dd3fe 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PublicSettingsController.java @@ -1,70 +1,70 @@ -package gg.modl.backend.settings.controller; - -import gg.modl.backend.admin.data.SystemConfig; -import gg.modl.backend.admin.service.GlobalSystemService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.GeneralSettings; -import gg.modl.backend.settings.data.SupportedLanguages; -import gg.modl.backend.settings.data.TicketFormSettings; -import gg.modl.backend.settings.service.GeneralSettingsService; -import gg.modl.backend.settings.service.TicketFormSettingsService; -import gg.modl.proto.modl.v1.PublicSettingsResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_SETTINGS) -@RequiredArgsConstructor -public class PublicSettingsController { - private final GeneralSettingsService generalSettingsService; - private final TicketFormSettingsService ticketFormSettingsService; - private final GlobalSystemService globalSystemService; - - @GetMapping - public PublicSettingsResponse getPublicSettings(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - - if (server == null) { - return getNotFoundSettings(); - } - - GeneralSettings generalSettings = generalSettingsService.getGeneralSettings(server); - TicketFormSettings ticketForms = ticketFormSettingsService.getTicketFormSettings(server); - SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); - return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( - true, - StringUtils.hasText(generalSettings.getServerDisplayName()) ? generalSettings.getServerDisplayName() : "modl", - generalSettings.getPanelIconUrl(), - generalSettings.getHomepageIconUrl(), - ticketFormSettingsService.buildTicketFormsResponse(ticketForms), - globalConfig.isMaintenanceMode(), - globalConfig.getMaintenanceMessage(), - generalSettings.getDefaultLanguage() - )); - } - - private PublicSettingsResponse getNotFoundSettings() { - SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); - return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( - false, - null, - null, - null, - Map.of(), - globalConfig.isMaintenanceMode(), - globalConfig.getMaintenanceMessage(), - SupportedLanguages.DEFAULT - )); - } - - private SystemConfig.GeneralConfig getGlobalMaintenanceConfig() { - return globalSystemService.getGeneralConfigOrDefault(); - } -} +package gg.modl.backend.settings.controller; + +import gg.modl.backend.admin.data.SystemConfig; +import gg.modl.backend.admin.service.GlobalSystemService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.GeneralSettings; +import gg.modl.backend.settings.data.SupportedLanguages; +import gg.modl.backend.settings.data.TicketFormSettings; +import gg.modl.backend.settings.service.GeneralSettingsService; +import gg.modl.backend.settings.service.TicketFormSettingsService; +import gg.modl.proto.modl.v1.PublicSettingsResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_SETTINGS) +@RequiredArgsConstructor +public class PublicSettingsController { + private final GeneralSettingsService generalSettingsService; + private final TicketFormSettingsService ticketFormSettingsService; + private final GlobalSystemService globalSystemService; + + @GetMapping + public PublicSettingsResponse getPublicSettings(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + + if (server == null) { + return getNotFoundSettings(); + } + + GeneralSettings generalSettings = generalSettingsService.getGeneralSettings(server); + TicketFormSettings ticketForms = ticketFormSettingsService.getTicketFormSettings(server); + SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); + return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( + true, + StringUtils.hasText(generalSettings.getServerDisplayName()) ? generalSettings.getServerDisplayName() : "modl", + generalSettings.getPanelIconUrl(), + generalSettings.getHomepageIconUrl(), + ticketFormSettingsService.buildTicketFormsResponse(ticketForms), + globalConfig.isMaintenanceMode(), + globalConfig.getMaintenanceMessage(), + generalSettings.getDefaultLanguage() + )); + } + + private PublicSettingsResponse getNotFoundSettings() { + SystemConfig.GeneralConfig globalConfig = getGlobalMaintenanceConfig(); + return PanelSettingsProtoMapper.toPublicSettingsResponse(new gg.modl.backend.settings.dto.response.PublicSettingsResponse( + false, + null, + null, + null, + Map.of(), + globalConfig.isMaintenanceMode(), + globalConfig.getMaintenanceMessage(), + SupportedLanguages.DEFAULT + )); + } + + private SystemConfig.GeneralConfig getGlobalMaintenanceConfig() { + return globalSystemService.getGeneralConfigOrDefault(); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java b/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java index cbe1be3..43c70a4 100644 --- a/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/AIModerationSettings.java @@ -1,41 +1,41 @@ -package gg.modl.backend.settings.data; - -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import java.util.HashMap; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class AIModerationSettings { - private boolean enableAIReview; - private boolean enableAutomatedActions; - @Builder.Default - @Size(max = RequestValidationLimits.AI_PUNISHMENT_CONFIGS_MAX_ENTRIES) - private Map aiPunishmentConfigs = new HashMap<>(); - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class AIPunishmentConfig { - @NotBlank - @Size(max = RequestValidationLimits.AI_PUNISHMENT_ID_MAX_LENGTH) - private String id; - @NotBlank - @Size(max = RequestValidationLimits.AI_PUNISHMENT_NAME_MAX_LENGTH) - private String name; - @NotBlank - @Size(max = RequestValidationLimits.AI_PUNISHMENT_DESCRIPTION_MAX_LENGTH) - private String aiDescription; - private boolean enabled; - } -} +package gg.modl.backend.settings.data; + +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.HashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AIModerationSettings { + private boolean enableAIReview; + private boolean enableAutomatedActions; + @Builder.Default + @Size(max = RequestValidationLimits.AI_PUNISHMENT_CONFIGS_MAX_ENTRIES) + private Map aiPunishmentConfigs = new HashMap<>(); + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class AIPunishmentConfig { + @NotBlank + @Size(max = RequestValidationLimits.AI_PUNISHMENT_ID_MAX_LENGTH) + private String id; + @NotBlank + @Size(max = RequestValidationLimits.AI_PUNISHMENT_NAME_MAX_LENGTH) + private String name; + @NotBlank + @Size(max = RequestValidationLimits.AI_PUNISHMENT_DESCRIPTION_MAX_LENGTH) + private String aiDescription; + private boolean enabled; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/AppealForm.java b/src/main/java/gg/modl/backend/settings/data/AppealForm.java index 1f27153..be148b0 100644 --- a/src/main/java/gg/modl/backend/settings/data/AppealForm.java +++ b/src/main/java/gg/modl/backend/settings/data/AppealForm.java @@ -1,21 +1,21 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class AppealForm { - @Builder.Default - private List fields = new ArrayList<>(); - @Builder.Default - private List sections = new ArrayList<>(); -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppealForm { + @Builder.Default + private List fields = new ArrayList<>(); + @Builder.Default + private List sections = new ArrayList<>(); +} diff --git a/src/main/java/gg/modl/backend/settings/data/AppealFormField.java b/src/main/java/gg/modl/backend/settings/data/AppealFormField.java index 2126bcf..1a361ba 100644 --- a/src/main/java/gg/modl/backend/settings/data/AppealFormField.java +++ b/src/main/java/gg/modl/backend/settings/data/AppealFormField.java @@ -1,22 +1,22 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class AppealFormField { - private String id; - private String type; - private String label; - private String description; - private Boolean required; - private Integer order; - private String sectionId; -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppealFormField { + private String id; + private String type; + private String label; + private String description; + private Boolean required; + private Integer order; + private String sectionId; +} diff --git a/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java b/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java index 0416ea8..90aa15e 100644 --- a/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java +++ b/src/main/java/gg/modl/backend/settings/data/AppealFormSection.java @@ -1,19 +1,19 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class AppealFormSection { - private String id; - private String title; - private String description; - private Integer order; -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppealFormSection { + private String id; + private String title; + private String description; + private Integer order; +} diff --git a/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java b/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java index 9afca6f..c12acde 100644 --- a/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java +++ b/src/main/java/gg/modl/backend/settings/data/DefaultPunishmentTypes.java @@ -1,541 +1,541 @@ -package gg.modl.backend.settings.data; - -import java.util.ArrayList; -import java.util.List; - -public final class DefaultPunishmentTypes { - private DefaultPunishmentTypes() {} - - public static List getAll() { - List types = new ArrayList<>(); - types.addAll(getAdministrativeTypes()); - types.addAll(getSocialTypes()); - types.addAll(getGameplayTypes()); - return types; - } - - public static List getAdministrativeTypes() { - return List.of( - createKick(), - createManualMute(), - createManualBan(), - createSecurityBan(), - createLinkedBan(), - createBlacklist() - ); - } - - private static PunishmentType createKick() { - return PunishmentType.builder() - .id(0) - .name("Kick") - .category("Administrative") - .customizable(false) - .ordinal(0) - .staffDescription("Kick a player.") - .playerDescription("BOOT!") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(false) - .build(); - } - - private static PunishmentType createManualMute() { - return PunishmentType.builder() - .id(1) - .name("Manual Mute") - .category("Administrative") - .customizable(false) - .ordinal(1) - .staffDescription("Manually mute a player.") - .playerDescription("You have been silenced.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static AppealForm createDefaultAppealForm() { - return AppealForm.builder() - .sections(List.of( - AppealFormSection.builder() - .id("appeal_reason_section") - .title("Appeal Information") - .description("Explain why you believe this punishment should be amended") - .order(0) - .build() - )) - .fields(List.of( - AppealFormField.builder() - .id("why") - .type("textarea") - .label("Why should this punishment be amended?") - .description("Please provide context and any relevant information to support your appeal") - .required(true) - .order(1) - .sectionId("appeal_reason_section") - .build() - )) - .build(); - } - - private static PunishmentType createManualBan() { - return PunishmentType.builder() - .id(2) - .name("Manual Ban") - .category("Administrative") - .customizable(false) - .ordinal(2) - .staffDescription("Manually ban a player.") - .playerDescription("The ban hammer has spoken.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createSecurityBan() { - return PunishmentType.builder() - .id(3) - .name("Security Ban") - .category("Administrative") - .customizable(false) - .ordinal(3) - .staffDescription("Compromised or potentially compromised account.") - .playerDescription("Suspicious activity has been detected on your account. Please secure your account and appeal this ban.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createLinkedBan() { - return PunishmentType.builder() - .id(4) - .name("Linked Ban") - .category("Administrative") - .customizable(false) - .ordinal(4) - .staffDescription("Usually automatically applied due to ban evasion.") - .playerDescription("Evading bans through the use of alternate accounts or sharing your account is strictly prohibited.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBlacklist() { - return PunishmentType.builder() - .id(5) - .name("Blacklist") - .category("Administrative") - .customizable(false) - .ordinal(5) - .staffDescription("Remove a player (unappealable).") - .playerDescription("You are blacklisted from the server.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(false) - .build(); - } - - public static List getSocialTypes() { - return List.of( - createChatAbuse(), - createAntiSocial(), - createTargeting(), - createBadContent(), - createBadUsername(), - createBadSkin() - ); - } - - private static PunishmentType createChatAbuse() { - return PunishmentType.builder() - .id(8) - .name("Chat Abuse") - .category("Social") - .customizable(true) - .ordinal(6) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(6, "hours", "mute"), - new DurationDetail(1, "days", "mute"), - new DurationDetail(3, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(1, "days", "mute"), - new DurationDetail(3, "days", "mute"), - new DurationDetail(7, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(3, "days", "mute"), - new DurationDetail(7, "days", "mute"), - new DurationDetail(14, "days", "mute") - ) - )) - .points(new PunishmentPoints(1, 1, 2)) - .staffDescription("Inappropriate language, excessive caps, or disruptive chat behavior.") - .playerDescription("Public chat channels are reserved for decent messages.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createAntiSocial() { - return PunishmentType.builder() - .id(9) - .name("Anti Social") - .category("Social") - .customizable(true) - .ordinal(7) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(3, "days", "mute"), - new DurationDetail(7, "days", "mute"), - new DurationDetail(14, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(7, "days", "mute"), - new DurationDetail(30, "days", "mute"), - new DurationDetail(90, "days", "mute") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "mute"), - new DurationDetail(90, "days", "mute"), - new DurationDetail(180, "days", "mute") - ) - )) - .points(new PunishmentPoints(2, 3, 4)) - .staffDescription("Hostile, toxic, or antisocial behavior that creates a negative environment.") - .playerDescription("Anti-social and disruptive behavior is strictly prohibited from public channels.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createTargeting() { - return PunishmentType.builder() - .id(10) - .name("Targeting") - .category("Social") - .customizable(true) - .ordinal(8) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban"), - new DurationDetail(365, "days", "ban") - ) - )) - .points(new PunishmentPoints(4, 6, 10)) - .staffDescription("Persistent harassment, bullying, or targeting of specific players with malicious intent.") - .playerDescription("This server has a zero tolerance policy on targeting individuals.") - .canBeAltBlocking(true) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBadContent() { - return PunishmentType.builder() - .id(11) - .name("Bad Content") - .category("Social") - .customizable(true) - .ordinal(9) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(1, "days", "ban"), - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(60, "days", "ban"), - new DurationDetail(90, "days", "ban") - ) - )) - .points(new PunishmentPoints(3, 4, 5)) - .staffDescription("Inappropriate content including sexual references, doxxing, links to harmful sites.") - .playerDescription("Sharing inappropriate content of any kind is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBadUsername() { - return PunishmentType.builder() - .id(18) - .name("Bad Username") - .category("Social") - .customizable(true) - .ordinal(10) - .permanentUntilUsernameChange(true) - .staffDescription("Username violates server guidelines (inappropriate, offensive, or misleading).") - .playerDescription("Your username violates our community guidelines. Please change your username to something appropriate to continue playing.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createBadSkin() { - return PunishmentType.builder() - .id(19) - .name("Bad Skin") - .category("Social") - .customizable(true) - .ordinal(11) - .permanentUntilSkinChange(true) - .staffDescription("Player skin violates server guidelines (inappropriate, offensive, or misleading).") - .playerDescription("Your Minecraft skin violates our community guidelines. Please change your skin to something appropriate to continue playing.") - .canBeAltBlocking(false) - .canBeStatWiping(false) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - public static List getGameplayTypes() { - return List.of( - createTeamAbuse(), - createGameAbuse(), - createCheating(), - createGameTrading(), - createAccountAbuse(), - createSystemsAbuse() - ); - } - - private static PunishmentType createTeamAbuse() { - return PunishmentType.builder() - .id(12) - .name("Team Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(12) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(6, "hours", "ban"), - new DurationDetail(12, "hours", "ban"), - new DurationDetail(3, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(12, "hours", "ban"), - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban") - ) - )) - .points(new PunishmentPoints(2, 2, 3)) - .staffDescription("Intentionally harming teammates, cross-teaming, or aiding cheaters.") - .playerDescription("Please be considerate to fellow players by not team-griefing, aiding cheaters, or cross-teaming.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createGameAbuse() { - return PunishmentType.builder() - .id(13) - .name("Game Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(13) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(1, "days", "ban"), - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(14, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban") - ) - )) - .points(new PunishmentPoints(2, 3, 5)) - .staffDescription("Violating game specific rules for fair play.") - .playerDescription("Violating game specific rules for competitive fair-play.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createCheating() { - return PunishmentType.builder() - .id(14) - .name("Cheating") - .category("Gameplay") - .customizable(true) - .ordinal(14) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(3, "days", "ban"), - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(60, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(0, "days", "permanent ban") - ) - )) - .points(new PunishmentPoints(5, 7, 9)) - .staffDescription("Using hacks, mods, exploits, or other software to gain an unfair advantage.") - .playerDescription("Cheating through the use of client-side modifications or game exploits is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createGameTrading() { - return PunishmentType.builder() - .id(15) - .name("Game Trading") - .category("Gameplay") - .customizable(true) - .ordinal(15) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(60, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban") - ) - )) - .points(new PunishmentPoints(4, 6, 10)) - .staffDescription("Trading or selling in-game items, content, or services on unauthorized third-party platforms.") - .playerDescription("Trading or selling in-game items on unauthorized third-party platforms is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createAccountAbuse() { - return PunishmentType.builder() - .id(16) - .name("Account Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(16) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(60, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban"), - new DurationDetail(0, "days", "permanent ban") - ) - )) - .points(new PunishmentPoints(4, 6, 10)) - .staffDescription("Account sharing, alt-account boosting, selling/trading accounts.") - .playerDescription("Misuse of accounts for financial or levelling gain is prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } - - private static PunishmentType createSystemsAbuse() { - return PunishmentType.builder() - .id(17) - .name("Systems Abuse") - .category("Gameplay") - .customizable(true) - .ordinal(17) - .durations(new PunishmentDurations( - new OffenseLevelDurations( - new DurationDetail(3, "days", "ban"), - new DurationDetail(7, "days", "ban"), - new DurationDetail(14, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(14, "days", "ban"), - new DurationDetail(30, "days", "ban"), - new DurationDetail(90, "days", "ban") - ), - new OffenseLevelDurations( - new DurationDetail(90, "days", "ban"), - new DurationDetail(180, "days", "ban"), - new DurationDetail(365, "days", "ban") - ) - )) - .points(new PunishmentPoints(2, 3, 5)) - .staffDescription("Abusing server functions by opening redundant tickets, creating lag machines, etc.") - .playerDescription("Using server systems in an unintended and harmful way is strictly prohibited.") - .canBeAltBlocking(true) - .canBeStatWiping(true) - .appealable(true) - .appealForm(createDefaultAppealForm()) - .build(); - } -} +package gg.modl.backend.settings.data; + +import java.util.ArrayList; +import java.util.List; + +public final class DefaultPunishmentTypes { + private DefaultPunishmentTypes() {} + + public static List getAll() { + List types = new ArrayList<>(); + types.addAll(getAdministrativeTypes()); + types.addAll(getSocialTypes()); + types.addAll(getGameplayTypes()); + return types; + } + + public static List getAdministrativeTypes() { + return List.of( + createKick(), + createManualMute(), + createManualBan(), + createSecurityBan(), + createLinkedBan(), + createBlacklist() + ); + } + + private static PunishmentType createKick() { + return PunishmentType.builder() + .id(0) + .name("Kick") + .category("Administrative") + .customizable(false) + .ordinal(0) + .staffDescription("Kick a player.") + .playerDescription("BOOT!") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(false) + .build(); + } + + private static PunishmentType createManualMute() { + return PunishmentType.builder() + .id(1) + .name("Manual Mute") + .category("Administrative") + .customizable(false) + .ordinal(1) + .staffDescription("Manually mute a player.") + .playerDescription("You have been silenced.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static AppealForm createDefaultAppealForm() { + return AppealForm.builder() + .sections(List.of( + AppealFormSection.builder() + .id("appeal_reason_section") + .title("Appeal Information") + .description("Explain why you believe this punishment should be amended") + .order(0) + .build() + )) + .fields(List.of( + AppealFormField.builder() + .id("why") + .type("textarea") + .label("Why should this punishment be amended?") + .description("Please provide context and any relevant information to support your appeal") + .required(true) + .order(1) + .sectionId("appeal_reason_section") + .build() + )) + .build(); + } + + private static PunishmentType createManualBan() { + return PunishmentType.builder() + .id(2) + .name("Manual Ban") + .category("Administrative") + .customizable(false) + .ordinal(2) + .staffDescription("Manually ban a player.") + .playerDescription("The ban hammer has spoken.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createSecurityBan() { + return PunishmentType.builder() + .id(3) + .name("Security Ban") + .category("Administrative") + .customizable(false) + .ordinal(3) + .staffDescription("Compromised or potentially compromised account.") + .playerDescription("Suspicious activity has been detected on your account. Please secure your account and appeal this ban.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createLinkedBan() { + return PunishmentType.builder() + .id(4) + .name("Linked Ban") + .category("Administrative") + .customizable(false) + .ordinal(4) + .staffDescription("Usually automatically applied due to ban evasion.") + .playerDescription("Evading bans through the use of alternate accounts or sharing your account is strictly prohibited.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBlacklist() { + return PunishmentType.builder() + .id(5) + .name("Blacklist") + .category("Administrative") + .customizable(false) + .ordinal(5) + .staffDescription("Remove a player (unappealable).") + .playerDescription("You are blacklisted from the server.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(false) + .build(); + } + + public static List getSocialTypes() { + return List.of( + createChatAbuse(), + createAntiSocial(), + createTargeting(), + createBadContent(), + createBadUsername(), + createBadSkin() + ); + } + + private static PunishmentType createChatAbuse() { + return PunishmentType.builder() + .id(8) + .name("Chat Abuse") + .category("Social") + .customizable(true) + .ordinal(6) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(6, "hours", "mute"), + new DurationDetail(1, "days", "mute"), + new DurationDetail(3, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(1, "days", "mute"), + new DurationDetail(3, "days", "mute"), + new DurationDetail(7, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(3, "days", "mute"), + new DurationDetail(7, "days", "mute"), + new DurationDetail(14, "days", "mute") + ) + )) + .points(new PunishmentPoints(1, 1, 2)) + .staffDescription("Inappropriate language, excessive caps, or disruptive chat behavior.") + .playerDescription("Public chat channels are reserved for decent messages.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createAntiSocial() { + return PunishmentType.builder() + .id(9) + .name("Anti Social") + .category("Social") + .customizable(true) + .ordinal(7) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(3, "days", "mute"), + new DurationDetail(7, "days", "mute"), + new DurationDetail(14, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(7, "days", "mute"), + new DurationDetail(30, "days", "mute"), + new DurationDetail(90, "days", "mute") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "mute"), + new DurationDetail(90, "days", "mute"), + new DurationDetail(180, "days", "mute") + ) + )) + .points(new PunishmentPoints(2, 3, 4)) + .staffDescription("Hostile, toxic, or antisocial behavior that creates a negative environment.") + .playerDescription("Anti-social and disruptive behavior is strictly prohibited from public channels.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createTargeting() { + return PunishmentType.builder() + .id(10) + .name("Targeting") + .category("Social") + .customizable(true) + .ordinal(8) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban"), + new DurationDetail(365, "days", "ban") + ) + )) + .points(new PunishmentPoints(4, 6, 10)) + .staffDescription("Persistent harassment, bullying, or targeting of specific players with malicious intent.") + .playerDescription("This server has a zero tolerance policy on targeting individuals.") + .canBeAltBlocking(true) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBadContent() { + return PunishmentType.builder() + .id(11) + .name("Bad Content") + .category("Social") + .customizable(true) + .ordinal(9) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(1, "days", "ban"), + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(60, "days", "ban"), + new DurationDetail(90, "days", "ban") + ) + )) + .points(new PunishmentPoints(3, 4, 5)) + .staffDescription("Inappropriate content including sexual references, doxxing, links to harmful sites.") + .playerDescription("Sharing inappropriate content of any kind is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBadUsername() { + return PunishmentType.builder() + .id(18) + .name("Bad Username") + .category("Social") + .customizable(true) + .ordinal(10) + .permanentUntilUsernameChange(true) + .staffDescription("Username violates server guidelines (inappropriate, offensive, or misleading).") + .playerDescription("Your username violates our community guidelines. Please change your username to something appropriate to continue playing.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createBadSkin() { + return PunishmentType.builder() + .id(19) + .name("Bad Skin") + .category("Social") + .customizable(true) + .ordinal(11) + .permanentUntilSkinChange(true) + .staffDescription("Player skin violates server guidelines (inappropriate, offensive, or misleading).") + .playerDescription("Your Minecraft skin violates our community guidelines. Please change your skin to something appropriate to continue playing.") + .canBeAltBlocking(false) + .canBeStatWiping(false) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + public static List getGameplayTypes() { + return List.of( + createTeamAbuse(), + createGameAbuse(), + createCheating(), + createGameTrading(), + createAccountAbuse(), + createSystemsAbuse() + ); + } + + private static PunishmentType createTeamAbuse() { + return PunishmentType.builder() + .id(12) + .name("Team Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(12) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(6, "hours", "ban"), + new DurationDetail(12, "hours", "ban"), + new DurationDetail(3, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(12, "hours", "ban"), + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban") + ) + )) + .points(new PunishmentPoints(2, 2, 3)) + .staffDescription("Intentionally harming teammates, cross-teaming, or aiding cheaters.") + .playerDescription("Please be considerate to fellow players by not team-griefing, aiding cheaters, or cross-teaming.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createGameAbuse() { + return PunishmentType.builder() + .id(13) + .name("Game Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(13) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(1, "days", "ban"), + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(14, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban") + ) + )) + .points(new PunishmentPoints(2, 3, 5)) + .staffDescription("Violating game specific rules for fair play.") + .playerDescription("Violating game specific rules for competitive fair-play.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createCheating() { + return PunishmentType.builder() + .id(14) + .name("Cheating") + .category("Gameplay") + .customizable(true) + .ordinal(14) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(3, "days", "ban"), + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(60, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(0, "days", "permanent ban") + ) + )) + .points(new PunishmentPoints(5, 7, 9)) + .staffDescription("Using hacks, mods, exploits, or other software to gain an unfair advantage.") + .playerDescription("Cheating through the use of client-side modifications or game exploits is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createGameTrading() { + return PunishmentType.builder() + .id(15) + .name("Game Trading") + .category("Gameplay") + .customizable(true) + .ordinal(15) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(60, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban") + ) + )) + .points(new PunishmentPoints(4, 6, 10)) + .staffDescription("Trading or selling in-game items, content, or services on unauthorized third-party platforms.") + .playerDescription("Trading or selling in-game items on unauthorized third-party platforms is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createAccountAbuse() { + return PunishmentType.builder() + .id(16) + .name("Account Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(16) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(60, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban"), + new DurationDetail(0, "days", "permanent ban") + ) + )) + .points(new PunishmentPoints(4, 6, 10)) + .staffDescription("Account sharing, alt-account boosting, selling/trading accounts.") + .playerDescription("Misuse of accounts for financial or levelling gain is prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } + + private static PunishmentType createSystemsAbuse() { + return PunishmentType.builder() + .id(17) + .name("Systems Abuse") + .category("Gameplay") + .customizable(true) + .ordinal(17) + .durations(new PunishmentDurations( + new OffenseLevelDurations( + new DurationDetail(3, "days", "ban"), + new DurationDetail(7, "days", "ban"), + new DurationDetail(14, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(14, "days", "ban"), + new DurationDetail(30, "days", "ban"), + new DurationDetail(90, "days", "ban") + ), + new OffenseLevelDurations( + new DurationDetail(90, "days", "ban"), + new DurationDetail(180, "days", "ban"), + new DurationDetail(365, "days", "ban") + ) + )) + .points(new PunishmentPoints(2, 3, 5)) + .staffDescription("Abusing server functions by opening redundant tickets, creating lag machines, etc.") + .playerDescription("Using server systems in an unintended and harmful way is strictly prohibited.") + .canBeAltBlocking(true) + .canBeStatWiping(true) + .appealable(true) + .appealForm(createDefaultAppealForm()) + .build(); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/DomainSettings.java b/src/main/java/gg/modl/backend/settings/data/DomainSettings.java index 33791b6..62fd430 100644 --- a/src/main/java/gg/modl/backend/settings/data/DomainSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/DomainSettings.java @@ -1,33 +1,33 @@ -package gg.modl.backend.settings.data; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor +package gg.modl.backend.settings.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class DomainSettings { private String customDomain; private DomainStatus status; private boolean accessingFromCustomDomain; private String modlSubdomainUrl; private boolean canManageCustomDomain; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class DomainStatus { - private String domain; - @Builder.Default - private String status = "pending"; // pending, active, error, verifying - private boolean cnameConfigured; - @Builder.Default - private String sslStatus = "pending"; // pending, active, error - private String lastChecked; - private String error; - } -} + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class DomainStatus { + private String domain; + @Builder.Default + private String status = "pending"; // pending, active, error, verifying + private boolean cnameConfigured; + @Builder.Default + private String sslStatus = "pending"; // pending, active, error + private String lastChecked; + private String error; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/DurationDetail.java b/src/main/java/gg/modl/backend/settings/data/DurationDetail.java index 56d91b1..089d05e 100644 --- a/src/main/java/gg/modl/backend/settings/data/DurationDetail.java +++ b/src/main/java/gg/modl/backend/settings/data/DurationDetail.java @@ -1,51 +1,51 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; - -@JsonIgnoreProperties(ignoreUnknown = true) -public record DurationDetail( - @Min(MIN_DURATION_VALUE) - @Max(MAX_DURATION_VALUE) - int value, - String unit, - String type -) { - private static final long MIN_DURATION_VALUE = 0L; - private static final long MAX_DURATION_VALUE = 100_000_000L; - - public long toMilliseconds() { - // Handle permanent punishments - if (isPermanent()) { - return -1L; - } - - // Handle null or empty unit - shouldn't happen but be defensive - if (unit == null || unit.isEmpty()) { - return -1L; // Treat as permanent if unit is missing - } - - return switch (unit.toLowerCase()) { - case "seconds", "second" -> value * 1000L; - case "minutes", "minute" -> value * 60L * 1000L; - case "hours", "hour" -> value * 60L * 60L * 1000L; - case "days", "day" -> value * 24L * 60L * 60L * 1000L; - case "weeks", "week" -> value * 7L * 24L * 60L * 60L * 1000L; - case "months", "month" -> value * 30L * 24L * 60L * 60L * 1000L; - default -> -1L; // Treat unknown unit as permanent rather than instant - }; - } - - public boolean isPermanent() { - return "permanent ban".equals(type) || "permanent mute".equals(type); - } - - public boolean isBan() { - return "ban".equals(type) || "permanent ban".equals(type); - } - - public boolean isMute() { - return "mute".equals(type) || "permanent mute".equals(type); - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record DurationDetail( + @Min(MIN_DURATION_VALUE) + @Max(MAX_DURATION_VALUE) + int value, + String unit, + String type +) { + private static final long MIN_DURATION_VALUE = 0L; + private static final long MAX_DURATION_VALUE = 100_000_000L; + + public long toMilliseconds() { + // Handle permanent punishments + if (isPermanent()) { + return -1L; + } + + // Handle null or empty unit - shouldn't happen but be defensive + if (unit == null || unit.isEmpty()) { + return -1L; // Treat as permanent if unit is missing + } + + return switch (unit.toLowerCase()) { + case "seconds", "second" -> value * 1000L; + case "minutes", "minute" -> value * 60L * 1000L; + case "hours", "hour" -> value * 60L * 60L * 1000L; + case "days", "day" -> value * 24L * 60L * 60L * 1000L; + case "weeks", "week" -> value * 7L * 24L * 60L * 60L * 1000L; + case "months", "month" -> value * 30L * 24L * 60L * 60L * 1000L; + default -> -1L; // Treat unknown unit as permanent rather than instant + }; + } + + public boolean isPermanent() { + return "permanent ban".equals(type) || "permanent mute".equals(type); + } + + public boolean isBan() { + return "ban".equals(type) || "permanent ban".equals(type); + } + + public boolean isMute() { + return "mute".equals(type) || "permanent mute".equals(type); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java b/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java index 7c24eff..d6570c3 100644 --- a/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/GeneralSettings.java @@ -1,5 +1,5 @@ -package gg.modl.backend.settings.data; - +package gg.modl.backend.settings.data; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; diff --git a/src/main/java/gg/modl/backend/settings/data/Label.java b/src/main/java/gg/modl/backend/settings/data/Label.java index 1574eed..5e8e667 100644 --- a/src/main/java/gg/modl/backend/settings/data/Label.java +++ b/src/main/java/gg/modl/backend/settings/data/Label.java @@ -1,17 +1,17 @@ -package gg.modl.backend.settings.data; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class Label { - private String id; // UUID - private String name; // e.g., "bug", "critical" - private String color; // Hex color e.g., "#d73a4a" - private String description; // Optional description -} +package gg.modl.backend.settings.data; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Label { + private String id; // UUID + private String name; // e.g., "bug", "critical" + private String color; // Hex color e.g., "#d73a4a" + private String description; // Optional description +} diff --git a/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java b/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java index 0f07bc0..e140a3a 100644 --- a/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java @@ -1,134 +1,134 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * Configurable thresholds for offender levels based on points. - * Used to determine if a player is Low, Medium, or Habitual offender. - * Separate thresholds for Social and Gameplay categories. - */ -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class OffenderThresholdSettings { - /** - * Thresholds for social category punishments. - * Default: medium=4, habitual=8 - */ - @Builder.Default - private CategoryThresholds social = new CategoryThresholds(4, 8); - - /** - * Thresholds for gameplay category punishments. - * Default: medium=5, habitual=10 - */ - @Builder.Default - private CategoryThresholds gameplay = new CategoryThresholds(5, 10); - - public static OffenderThresholdSettings defaults() { - return OffenderThresholdSettings.builder().build(); - } - - /** - * Get the social offender level for display. - */ - public String getSocialOffenderLevel(int points) { - return social.getOffenderLevel(points); - } - - /** - * Get the gameplay offender level for display. - */ - public String getGameplayOffenderLevel(int points) { - return gameplay.getOffenderLevel(points); - } - - /** - * Get the internal offense level for a category (for duration lookup). - */ - public String getOffenseLevelInternal(int points, boolean isSocial) { - return isSocial ? social.getOffenseLevelInternal(points) : gameplay.getOffenseLevelInternal(points); - } - - @Data - @NoArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class CategoryThresholds { - /** - * Points threshold for Medium offender level. - * Players with points >= this value and < habitual are "Medium". - */ - private int medium; - - /** - * Points threshold for Habitual offender level. - * Players with points >= this value are "Habitual". - */ - private int habitual; - - /** - * Number of months after a punishment's effective expiry before its points stop counting. - * Default: 24 months (2 years). Permanent punishments always count. - */ - private int pointExpiryMonths = 24; - - public CategoryThresholds(int medium, int habitual) { - this.medium = medium; - this.habitual = habitual; - this.pointExpiryMonths = 24; - } - - public CategoryThresholds(int medium, int habitual, int pointExpiryMonths) { - this.medium = medium; - this.habitual = habitual; - this.pointExpiryMonths = pointExpiryMonths; - } - - /** - * Convert pointExpiryMonths to milliseconds for date comparison. - */ - @JsonIgnore - public long getPointExpiryMs() { - return (long) pointExpiryMonths * 30L * 24L * 60L * 60L * 1000L; - } - - /** - * Calculate the offender level based on points. - * - * @param points The player's offense points - * @return "Low", "Medium", or "Habitual" - */ - public String getOffenderLevel(int points) { - if (points >= habitual) { - return "Habitual"; - } else if (points >= medium) { - return "Medium"; - } else { - return "Low"; - } - } - - /** - * Get the internal offense level used for duration lookup. - * - * @param points The player's offense points - * @return "first", "medium", or "habitual" (for duration matrix lookup) - */ - public String getOffenseLevelInternal(int points) { - if (points >= habitual) { - return "habitual"; - } else if (points >= medium) { - return "medium"; - } else { - return "first"; - } - } - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Configurable thresholds for offender levels based on points. + * Used to determine if a player is Low, Medium, or Habitual offender. + * Separate thresholds for Social and Gameplay categories. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OffenderThresholdSettings { + /** + * Thresholds for social category punishments. + * Default: medium=4, habitual=8 + */ + @Builder.Default + private CategoryThresholds social = new CategoryThresholds(4, 8); + + /** + * Thresholds for gameplay category punishments. + * Default: medium=5, habitual=10 + */ + @Builder.Default + private CategoryThresholds gameplay = new CategoryThresholds(5, 10); + + public static OffenderThresholdSettings defaults() { + return OffenderThresholdSettings.builder().build(); + } + + /** + * Get the social offender level for display. + */ + public String getSocialOffenderLevel(int points) { + return social.getOffenderLevel(points); + } + + /** + * Get the gameplay offender level for display. + */ + public String getGameplayOffenderLevel(int points) { + return gameplay.getOffenderLevel(points); + } + + /** + * Get the internal offense level for a category (for duration lookup). + */ + public String getOffenseLevelInternal(int points, boolean isSocial) { + return isSocial ? social.getOffenseLevelInternal(points) : gameplay.getOffenseLevelInternal(points); + } + + @Data + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class CategoryThresholds { + /** + * Points threshold for Medium offender level. + * Players with points >= this value and < habitual are "Medium". + */ + private int medium; + + /** + * Points threshold for Habitual offender level. + * Players with points >= this value are "Habitual". + */ + private int habitual; + + /** + * Number of months after a punishment's effective expiry before its points stop counting. + * Default: 24 months (2 years). Permanent punishments always count. + */ + private int pointExpiryMonths = 24; + + public CategoryThresholds(int medium, int habitual) { + this.medium = medium; + this.habitual = habitual; + this.pointExpiryMonths = 24; + } + + public CategoryThresholds(int medium, int habitual, int pointExpiryMonths) { + this.medium = medium; + this.habitual = habitual; + this.pointExpiryMonths = pointExpiryMonths; + } + + /** + * Convert pointExpiryMonths to milliseconds for date comparison. + */ + @JsonIgnore + public long getPointExpiryMs() { + return (long) pointExpiryMonths * 30L * 24L * 60L * 60L * 1000L; + } + + /** + * Calculate the offender level based on points. + * + * @param points The player's offense points + * @return "Low", "Medium", or "Habitual" + */ + public String getOffenderLevel(int points) { + if (points >= habitual) { + return "Habitual"; + } else if (points >= medium) { + return "Medium"; + } else { + return "Low"; + } + } + + /** + * Get the internal offense level used for duration lookup. + * + * @param points The player's offense points + * @return "first", "medium", or "habitual" (for duration matrix lookup) + */ + public String getOffenseLevelInternal(int points) { + if (points >= habitual) { + return "habitual"; + } else if (points >= medium) { + return "medium"; + } else { + return "first"; + } + } + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java b/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java index 6f0223d..7410e7e 100644 --- a/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java +++ b/src/main/java/gg/modl/backend/settings/data/OffenseLevelDurations.java @@ -1,18 +1,18 @@ -package gg.modl.backend.settings.data; - -import jakarta.validation.Valid; - -public record OffenseLevelDurations( - @Valid DurationDetail first, - @Valid DurationDetail medium, - @Valid DurationDetail habitual -) { - public DurationDetail getForOffenseLevel(String offenseLevel) { - return switch (offenseLevel.toLowerCase()) { - case "first" -> first; - case "medium" -> medium; - case "habitual" -> habitual; - default -> first; - }; - } -} +package gg.modl.backend.settings.data; + +import jakarta.validation.Valid; + +public record OffenseLevelDurations( + @Valid DurationDetail first, + @Valid DurationDetail medium, + @Valid DurationDetail habitual +) { + public DurationDetail getForOffenseLevel(String offenseLevel) { + return switch (offenseLevel.toLowerCase()) { + case "first" -> first; + case "medium" -> medium; + case "habitual" -> habitual; + default -> first; + }; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java b/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java index 7d49bf4..782f91e 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentDurations.java @@ -1,18 +1,18 @@ -package gg.modl.backend.settings.data; - -import jakarta.validation.Valid; - -public record PunishmentDurations( - @Valid OffenseLevelDurations low, - @Valid OffenseLevelDurations regular, - @Valid OffenseLevelDurations severe -) { - public DurationDetail getDuration(String severity, String offenseLevel) { - OffenseLevelDurations severityDurations = getForSeverity(severity); - return severityDurations != null ? severityDurations.getForOffenseLevel(offenseLevel) : null; - } - - public OffenseLevelDurations getForSeverity(String severity) { - return SeverityLevel.select(severity, low, regular, severe); - } -} +package gg.modl.backend.settings.data; + +import jakarta.validation.Valid; + +public record PunishmentDurations( + @Valid OffenseLevelDurations low, + @Valid OffenseLevelDurations regular, + @Valid OffenseLevelDurations severe +) { + public DurationDetail getDuration(String severity, String offenseLevel) { + OffenseLevelDurations severityDurations = getForSeverity(severity); + return severityDurations != null ? severityDurations.getForOffenseLevel(offenseLevel) : null; + } + + public OffenseLevelDurations getForSeverity(String severity) { + return SeverityLevel.select(severity, low, regular, severe); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java b/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java index 329b1c9..89da1a4 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentPoints.java @@ -1,21 +1,21 @@ -package gg.modl.backend.settings.data; - -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; - -public record PunishmentPoints( - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - int low, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - int regular, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - int severe -) { - public int getForSeverity(String severity) { - return SeverityLevel.select(severity, low, regular, severe); - } -} +package gg.modl.backend.settings.data; + +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +public record PunishmentPoints( + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + int low, + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + int regular, + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + int severe +) { + public int getForSeverity(String severity) { + return SeverityLevel.select(severity, low, regular, severe); + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentType.java b/src/main/java/gg/modl/backend/settings/data/PunishmentType.java index 39d0b6e..ebec9a7 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentType.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentType.java @@ -1,149 +1,149 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder(toBuilder = true) -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class PunishmentType { - private Integer id; - private String name; - private String category; - private Boolean customizable; - private Integer ordinal; - - @Valid - private PunishmentDurations durations; - - private Boolean singleSeverityPunishment; - @Valid - private OffenseLevelDurations singleSeverityDurations; - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - private Integer singleSeverityPoints; - - @Valid - private PunishmentPoints points; - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - private Integer customPoints; - - private String staffDescription; - private String playerDescription; - - private Boolean canBeAltBlocking; - private Boolean canBeStatWiping; - private Boolean appealable; - - private AppealForm appealForm; - - private Boolean permanentUntilSkinChange; - private Boolean permanentUntilUsernameChange; - - public boolean isSocial() { - return "Social".equalsIgnoreCase(category); - } - - public boolean isGameplay() { - return "Gameplay".equalsIgnoreCase(category); - } - - public boolean isAdministrative() { - return "Administrative".equalsIgnoreCase(category); - } - - public boolean isBan() { - // Core types (ordinals 0-5) are hardcoded - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal >= 2; - } - return category != null && category.toLowerCase().contains("ban"); - } - - public boolean isMute() { - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal == 1; - } - return category != null && category.toLowerCase().contains("mute"); - } - - public boolean isKick() { - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal == 0; - } - return category != null && category.toLowerCase().contains("kick"); - } - - public int getPointsForSeverity(String severity) { - if (customPoints != null) { - return customPoints; - } - if (singleSeverityPoints != null) { - return singleSeverityPoints; - } - if (points != null) { - return points.getForSeverity(severity); - } - return 0; - } - - public long getDurationMillis(String severity, String offenseLevel) { - if (isSingleSeverityPunishment() && singleSeverityDurations != null) { - DurationDetail detail = singleSeverityDurations.getForOffenseLevel(offenseLevel); - return detail != null ? detail.toMilliseconds() : 0L; - } - if (durations != null) { - DurationDetail detail = durations.getDuration(severity, offenseLevel); - return detail != null ? detail.toMilliseconds() : 0L; - } - return 0L; - } - - public boolean isSingleSeverityPunishment() { - return singleSeverityPunishment != null && singleSeverityPunishment; - } - - public DurationDetail getDurationDetail(String severity, String offenseLevel) { - if (isSingleSeverityPunishment() && singleSeverityDurations != null) { - return singleSeverityDurations.getForOffenseLevel(offenseLevel); - } - if (durations != null) { - return durations.getDuration(severity, offenseLevel); - } - return null; - } - - public boolean isCustomizable() { - return customizable != null && customizable; - } - - public boolean isCanBeAltBlocking() { - return canBeAltBlocking != null && canBeAltBlocking; - } - - public boolean isCanBeStatWiping() { - return canBeStatWiping != null && canBeStatWiping; - } - - public boolean isAppealable() { - return appealable != null && appealable; - } - - public boolean isPermanentUntilSkinChange() { - return permanentUntilSkinChange != null && permanentUntilSkinChange; - } - - public boolean isPermanentUntilUsernameChange() { - return permanentUntilUsernameChange != null && permanentUntilUsernameChange; - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class PunishmentType { + private Integer id; + private String name; + private String category; + private Boolean customizable; + private Integer ordinal; + + @Valid + private PunishmentDurations durations; + + private Boolean singleSeverityPunishment; + @Valid + private OffenseLevelDurations singleSeverityDurations; + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + private Integer singleSeverityPoints; + + @Valid + private PunishmentPoints points; + @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) + @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) + private Integer customPoints; + + private String staffDescription; + private String playerDescription; + + private Boolean canBeAltBlocking; + private Boolean canBeStatWiping; + private Boolean appealable; + + private AppealForm appealForm; + + private Boolean permanentUntilSkinChange; + private Boolean permanentUntilUsernameChange; + + public boolean isSocial() { + return "Social".equalsIgnoreCase(category); + } + + public boolean isGameplay() { + return "Gameplay".equalsIgnoreCase(category); + } + + public boolean isAdministrative() { + return "Administrative".equalsIgnoreCase(category); + } + + public boolean isBan() { + // Core types (ordinals 0-5) are hardcoded + if (ordinal != null && ordinal >= 0 && ordinal <= 5) { + return ordinal >= 2; + } + return category != null && category.toLowerCase().contains("ban"); + } + + public boolean isMute() { + if (ordinal != null && ordinal >= 0 && ordinal <= 5) { + return ordinal == 1; + } + return category != null && category.toLowerCase().contains("mute"); + } + + public boolean isKick() { + if (ordinal != null && ordinal >= 0 && ordinal <= 5) { + return ordinal == 0; + } + return category != null && category.toLowerCase().contains("kick"); + } + + public int getPointsForSeverity(String severity) { + if (customPoints != null) { + return customPoints; + } + if (singleSeverityPoints != null) { + return singleSeverityPoints; + } + if (points != null) { + return points.getForSeverity(severity); + } + return 0; + } + + public long getDurationMillis(String severity, String offenseLevel) { + if (isSingleSeverityPunishment() && singleSeverityDurations != null) { + DurationDetail detail = singleSeverityDurations.getForOffenseLevel(offenseLevel); + return detail != null ? detail.toMilliseconds() : 0L; + } + if (durations != null) { + DurationDetail detail = durations.getDuration(severity, offenseLevel); + return detail != null ? detail.toMilliseconds() : 0L; + } + return 0L; + } + + public boolean isSingleSeverityPunishment() { + return singleSeverityPunishment != null && singleSeverityPunishment; + } + + public DurationDetail getDurationDetail(String severity, String offenseLevel) { + if (isSingleSeverityPunishment() && singleSeverityDurations != null) { + return singleSeverityDurations.getForOffenseLevel(offenseLevel); + } + if (durations != null) { + return durations.getDuration(severity, offenseLevel); + } + return null; + } + + public boolean isCustomizable() { + return customizable != null && customizable; + } + + public boolean isCanBeAltBlocking() { + return canBeAltBlocking != null && canBeAltBlocking; + } + + public boolean isCanBeStatWiping() { + return canBeStatWiping != null && canBeStatWiping; + } + + public boolean isAppealable() { + return appealable != null && appealable; + } + + public boolean isPermanentUntilSkinChange() { + return permanentUntilSkinChange != null && permanentUntilSkinChange; + } + + public boolean isPermanentUntilUsernameChange() { + return permanentUntilUsernameChange != null && permanentUntilUsernameChange; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java b/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java index 357f385..ad5383f 100644 --- a/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/QuickResponseSettings.java @@ -1,65 +1,65 @@ -package gg.modl.backend.settings.data; - -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class QuickResponseSettings { - public static final int MAX_CATEGORIES = 100; - private static final int MAX_ACTIONS_PER_CATEGORY = 100; - private static final int MAX_TICKET_TYPES = 50; - private static final int NAME_MAX_LENGTH = 128; - private static final int MESSAGE_MAX_LENGTH = 10_000; - - @Builder.Default - private List categories = new ArrayList<>(); - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class Category { - @Size(max = RequestValidationLimits.QUICK_RESPONSE_CATEGORY_ID_MAX_LENGTH) - private String id; - @Size(max = NAME_MAX_LENGTH) - private String name; - @Builder.Default - @Size(max = MAX_TICKET_TYPES) - private List ticketTypes = new ArrayList<>(); - @Builder.Default - @Size(max = MAX_ACTIONS_PER_CATEGORY) - @Valid - private List actions = new ArrayList<>(); - private Integer order; - } - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - public static class Action { - @Size(max = RequestValidationLimits.QUICK_RESPONSE_ACTION_ID_MAX_LENGTH) - private String id; - @Size(max = NAME_MAX_LENGTH) - private String name; - @NotBlank - @Size(max = MESSAGE_MAX_LENGTH) - private String message; - private Integer order; - private Boolean closeTicket; - private Boolean showPunishment; - @Size(max = RequestValidationLimits.QUICK_RESPONSE_APPEAL_ACTION_MAX_LENGTH) - private String appealAction; - } -} +package gg.modl.backend.settings.data; + +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class QuickResponseSettings { + public static final int MAX_CATEGORIES = 100; + private static final int MAX_ACTIONS_PER_CATEGORY = 100; + private static final int MAX_TICKET_TYPES = 50; + private static final int NAME_MAX_LENGTH = 128; + private static final int MESSAGE_MAX_LENGTH = 10_000; + + @Builder.Default + private List categories = new ArrayList<>(); + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Category { + @Size(max = RequestValidationLimits.QUICK_RESPONSE_CATEGORY_ID_MAX_LENGTH) + private String id; + @Size(max = NAME_MAX_LENGTH) + private String name; + @Builder.Default + @Size(max = MAX_TICKET_TYPES) + private List ticketTypes = new ArrayList<>(); + @Builder.Default + @Size(max = MAX_ACTIONS_PER_CATEGORY) + @Valid + private List actions = new ArrayList<>(); + private Integer order; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Action { + @Size(max = RequestValidationLimits.QUICK_RESPONSE_ACTION_ID_MAX_LENGTH) + private String id; + @Size(max = NAME_MAX_LENGTH) + private String name; + @NotBlank + @Size(max = MESSAGE_MAX_LENGTH) + private String message; + private Integer order; + private Boolean closeTicket; + private Boolean showPunishment; + @Size(max = RequestValidationLimits.QUICK_RESPONSE_APPEAL_ACTION_MAX_LENGTH) + private String appealAction; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java b/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java index 1af18dd..82f4c13 100644 --- a/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/TicketFormSettings.java @@ -1,78 +1,78 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class TicketFormSettings { - private TicketForm bug; - private TicketForm support; - private TicketForm application; - private TicketForm player; - private TicketForm chat; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class TicketForm { - @Builder.Default - private boolean requireEmail = false; - @Builder.Default - private boolean requireEmailAuth = false; - private Boolean allowEmailNotifications; - @Builder.Default - private List fields = new ArrayList<>(); - @Builder.Default - private List sections = new ArrayList<>(); - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class FormField { - private String id; - private String type; - private String label; - private String description; - private boolean required; - @Builder.Default - private List options = new ArrayList<>(); - private int order; - private String sectionId; - private String goToSection; - @Builder.Default - private Map optionSectionMapping = new HashMap<>(); - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class FormSection { - private String id; - private String title; - private String description; - private int order; - private String showIfFieldId; - private String showIfValue; - @Builder.Default - private List showIfValues = new ArrayList<>(); - private boolean hideByDefault; - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class TicketFormSettings { + private TicketForm bug; + private TicketForm support; + private TicketForm application; + private TicketForm player; + private TicketForm chat; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TicketForm { + @Builder.Default + private boolean requireEmail = false; + @Builder.Default + private boolean requireEmailAuth = false; + private Boolean allowEmailNotifications; + @Builder.Default + private List fields = new ArrayList<>(); + @Builder.Default + private List sections = new ArrayList<>(); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FormField { + private String id; + private String type; + private String label; + private String description; + private boolean required; + @Builder.Default + private List options = new ArrayList<>(); + private int order; + private String sectionId; + private String goToSection; + @Builder.Default + private Map optionSectionMapping = new HashMap<>(); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class FormSection { + private String id; + private String title; + private String description; + private int order; + private String showIfFieldId; + private String showIfValue; + @Builder.Default + private List showIfValues = new ArrayList<>(); + private boolean hideByDefault; + } +} diff --git a/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java b/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java index 9f3135c..7adfe39 100644 --- a/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/WebhookSettings.java @@ -1,70 +1,70 @@ -package gg.modl.backend.settings.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import java.util.ArrayList; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -@JsonIgnoreProperties(ignoreUnknown = true) -public class WebhookSettings { - private String discordWebhookUrl; - private String discordAdminRoleId; - private String botName; - private String avatarUrl; - private boolean enabled; - private NotificationSettings notifications; - private EmbedTemplates embedTemplates; - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class NotificationSettings { - private boolean newTickets; - private boolean newPunishments; - private boolean auditLogs; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class EmbedTemplates { - private EmbedTemplate newTickets; - private EmbedTemplate newPunishments; - private EmbedTemplate auditLogs; - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class EmbedTemplate { - private String title; - private String description; - private String color; - @Builder.Default - private List fields = new ArrayList<>(); - } - - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - @JsonIgnoreProperties(ignoreUnknown = true) - public static class EmbedField { - private String name; - private String value; - private boolean inline; - } -} +package gg.modl.backend.settings.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class WebhookSettings { + private String discordWebhookUrl; + private String discordAdminRoleId; + private String botName; + private String avatarUrl; + private boolean enabled; + private NotificationSettings notifications; + private EmbedTemplates embedTemplates; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NotificationSettings { + private boolean newTickets; + private boolean newPunishments; + private boolean auditLogs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbedTemplates { + private EmbedTemplate newTickets; + private EmbedTemplate newPunishments; + private EmbedTemplate auditLogs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbedTemplate { + private String title; + private String description; + private String color; + @Builder.Default + private List fields = new ArrayList<>(); + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class EmbedField { + private String name; + private String value; + private boolean inline; + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java b/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java index 2543ace..83d8bad 100644 --- a/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java @@ -1,119 +1,119 @@ -package gg.modl.backend.settings.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.GeneralSettings; -import gg.modl.backend.settings.data.SupportedLanguages; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class GeneralSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; - private static final String SETTINGS_TYPE_GENERAL = "general"; - private static final int MAX_SERVER_NAME_LENGTH = 80; - private static final int MAX_URL_LENGTH = 2048; - - public GeneralSettings getGeneralSettings(Server server) { - return getGeneralSettingsState(server).data(); - } - - public VersionedSettings getGeneralSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); - GeneralSettings settings = mapToGeneralSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); - } - - private GeneralSettings mapToGeneralSettings(Map data) { - GeneralSettings mapped = codec().decode(data); - return GeneralSettings.builder() - .serverDisplayName(sanitizeOrEmpty(mapped.getServerDisplayName(), MAX_SERVER_NAME_LENGTH)) - .discordWebhookUrl(sanitizeOrEmpty(mapped.getDiscordWebhookUrl(), MAX_URL_LENGTH)) - .homepageIconUrl(sanitizeOrEmpty(mapped.getHomepageIconUrl(), MAX_URL_LENGTH)) - .panelIconUrl(sanitizeOrEmpty(mapped.getPanelIconUrl(), MAX_URL_LENGTH)) - .defaultLanguage(resolveLanguage(mapped.getDefaultLanguage())) - .build(); - } - - private String resolveLanguage(String value) { - return SupportedLanguages.isSupported(value) ? value : SupportedLanguages.DEFAULT; - } - - private SettingsCodec codec() { - return SettingsCodec.of(objectMapper, GeneralSettings.class, this::defaultGeneralSettings); - } - - private String sanitizeOrEmpty(String value, int maxLength) { - String sanitized = sanitize(value, maxLength); - return sanitized != null ? sanitized : ""; - } - - private GeneralSettings defaultGeneralSettings() { - return GeneralSettings.builder() - .serverDisplayName("") - .discordWebhookUrl("") - .homepageIconUrl("") - .panelIconUrl("") - .defaultLanguage(SupportedLanguages.DEFAULT) - .build(); - } - - private String sanitize(String value, int maxLength) { - if (value == null) { - return null; - } - - String trimmed = value.trim(); - if (trimmed.length() <= maxLength) { - return trimmed; - } - return trimmed.substring(0, maxLength); - } - - public VersionedSettings patchGeneralSettings( - Server server, - long expectedVersion, - GeneralSettings patch - ) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); - Map data = new LinkedHashMap<>(current.data()); - - putIfNotNull(data, "serverDisplayName", patch.getServerDisplayName(), MAX_SERVER_NAME_LENGTH); - putIfNotNull(data, "discordWebhookUrl", patch.getDiscordWebhookUrl(), MAX_URL_LENGTH); - putIfNotNull(data, "homepageIconUrl", patch.getHomepageIconUrl(), MAX_URL_LENGTH); - putIfNotNull(data, "panelIconUrl", patch.getPanelIconUrl(), MAX_URL_LENGTH); - putLanguageIfNotNull(data, patch.getDefaultLanguage()); - - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_GENERAL, - expectedVersion, - data - ); - return new VersionedSettings<>(mapToGeneralSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - private void putIfNotNull(Map data, String key, String value, int maxLength) { - if (value != null) { - data.put(key, sanitize(value, maxLength)); - } - } - - private void putLanguageIfNotNull(Map data, String value) { - if (value == null) { - return; - } - String language = value.trim(); - if (!SupportedLanguages.isSupported(language)) { - throw new ValidationException("Unsupported default language: " + language); - } - data.put("defaultLanguage", language); - } -} +package gg.modl.backend.settings.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.GeneralSettings; +import gg.modl.backend.settings.data.SupportedLanguages; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class GeneralSettingsService { + private final SettingsDocumentService settingsDocumentService; + private final ObjectMapper objectMapper; + private static final String SETTINGS_TYPE_GENERAL = "general"; + private static final int MAX_SERVER_NAME_LENGTH = 80; + private static final int MAX_URL_LENGTH = 2048; + + public GeneralSettings getGeneralSettings(Server server) { + return getGeneralSettingsState(server).data(); + } + + public VersionedSettings getGeneralSettingsState(Server server) { + SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); + GeneralSettings settings = mapToGeneralSettings(state.data()); + return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + } + + private GeneralSettings mapToGeneralSettings(Map data) { + GeneralSettings mapped = codec().decode(data); + return GeneralSettings.builder() + .serverDisplayName(sanitizeOrEmpty(mapped.getServerDisplayName(), MAX_SERVER_NAME_LENGTH)) + .discordWebhookUrl(sanitizeOrEmpty(mapped.getDiscordWebhookUrl(), MAX_URL_LENGTH)) + .homepageIconUrl(sanitizeOrEmpty(mapped.getHomepageIconUrl(), MAX_URL_LENGTH)) + .panelIconUrl(sanitizeOrEmpty(mapped.getPanelIconUrl(), MAX_URL_LENGTH)) + .defaultLanguage(resolveLanguage(mapped.getDefaultLanguage())) + .build(); + } + + private String resolveLanguage(String value) { + return SupportedLanguages.isSupported(value) ? value : SupportedLanguages.DEFAULT; + } + + private SettingsCodec codec() { + return SettingsCodec.of(objectMapper, GeneralSettings.class, this::defaultGeneralSettings); + } + + private String sanitizeOrEmpty(String value, int maxLength) { + String sanitized = sanitize(value, maxLength); + return sanitized != null ? sanitized : ""; + } + + private GeneralSettings defaultGeneralSettings() { + return GeneralSettings.builder() + .serverDisplayName("") + .discordWebhookUrl("") + .homepageIconUrl("") + .panelIconUrl("") + .defaultLanguage(SupportedLanguages.DEFAULT) + .build(); + } + + private String sanitize(String value, int maxLength) { + if (value == null) { + return null; + } + + String trimmed = value.trim(); + if (trimmed.length() <= maxLength) { + return trimmed; + } + return trimmed.substring(0, maxLength); + } + + public VersionedSettings patchGeneralSettings( + Server server, + long expectedVersion, + GeneralSettings patch + ) { + SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); + Map data = new LinkedHashMap<>(current.data()); + + putIfNotNull(data, "serverDisplayName", patch.getServerDisplayName(), MAX_SERVER_NAME_LENGTH); + putIfNotNull(data, "discordWebhookUrl", patch.getDiscordWebhookUrl(), MAX_URL_LENGTH); + putIfNotNull(data, "homepageIconUrl", patch.getHomepageIconUrl(), MAX_URL_LENGTH); + putIfNotNull(data, "panelIconUrl", patch.getPanelIconUrl(), MAX_URL_LENGTH); + putLanguageIfNotNull(data, patch.getDefaultLanguage()); + + SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( + server, + SETTINGS_TYPE_GENERAL, + expectedVersion, + data + ); + return new VersionedSettings<>(mapToGeneralSettings(updated.data()), updated.version(), updated.updatedAt()); + } + + private void putIfNotNull(Map data, String key, String value, int maxLength) { + if (value != null) { + data.put(key, sanitize(value, maxLength)); + } + } + + private void putLanguageIfNotNull(Map data, String value) { + if (value == null) { + return; + } + String language = value.trim(); + if (!SupportedLanguages.isSupported(language)) { + throw new ValidationException("Unsupported default language: " + language); + } + data.put("defaultLanguage", language); + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/IconUploadService.java b/src/main/java/gg/modl/backend/settings/service/IconUploadService.java index 6008372..132b33d 100644 --- a/src/main/java/gg/modl/backend/settings/service/IconUploadService.java +++ b/src/main/java/gg/modl/backend/settings/service/IconUploadService.java @@ -1,82 +1,82 @@ -package gg.modl.backend.settings.service; - -import gg.modl.backend.server.data.Server; -import gg.modl.backend.storage.service.S3StorageService; -import gg.modl.backend.storage.service.StorageMetadataService; -import gg.modl.backend.storage.service.StorageQuotaService; -import java.io.IOException; -import java.util.Map; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -@Service -@RequiredArgsConstructor -public class IconUploadService { - private final S3StorageService s3StorageService; - private final StorageQuotaService storageQuotaService; - private final StorageMetadataService storageMetadataService; - private static final Set ALLOWED_IMAGE_TYPES = Set.of( - "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp" - ); - private static final long MAX_ICON_SIZE = 2 * 1024 * 1024; - - public ResponseEntity uploadIcon(Server server, MultipartFile file, String iconType) { - ResponseEntity validationError = validateUpload(server, file, iconType); - if (validationError != null) { - return validationError; - } - - try { - String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "icon"; - S3StorageService.UploadFileResult result = s3StorageService.uploadFile( - server, "icons/" + iconType, fileName, file.getContentType(), file.getBytes() - ); - StorageQuotaService.ConfirmResult confirmResult = storageQuotaService.confirmAndRecordFile( - server, result.key(), file.getSize(), file.getContentType() - ); - switch (confirmResult) { - case QUOTA_EXCEEDED -> { - s3StorageService.deleteFile(result.key()); - return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); - } - case RECORD_FAILED -> { - s3StorageService.deleteFile(result.key()); - return ResponseEntity.internalServerError().body(Map.of("error", "Failed to record upload")); - } - default -> { - } - } - return ResponseEntity.ok(Map.of("url", result.cdnUrl())); - } catch (IOException e) { - return ResponseEntity.internalServerError().body(Map.of("error", "Failed to read file")); - } catch (RuntimeException e) { - return ResponseEntity.internalServerError().body(Map.of("error", "Failed to upload file")); - } - } - - private ResponseEntity validateUpload(Server server, MultipartFile file, String iconType) { - if (!iconType.equals("homepage") && !iconType.equals("panel")) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid icon type. Must be 'homepage' or 'panel'.")); - } - if (file.isEmpty()) { - return ResponseEntity.badRequest().body(Map.of("error", "No file uploaded")); - } - String contentType = file.getContentType(); - if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { - return ResponseEntity.badRequest().body(Map.of("error", "Invalid file type. Allowed: PNG, JPEG, GIF, WebP")); - } - if (file.getSize() > MAX_ICON_SIZE) { - return ResponseEntity.badRequest().body(Map.of("error", "File too large. Maximum size is 2MB.")); - } - if (!s3StorageService.isConfigured()) { - return ResponseEntity.status(503).body(Map.of("error", "File storage is not configured")); - } - if (!storageQuotaService.canUpload(server, file.getSize())) { - return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); - } - return null; - } -} +package gg.modl.backend.settings.service; + +import gg.modl.backend.server.data.Server; +import gg.modl.backend.storage.service.S3StorageService; +import gg.modl.backend.storage.service.StorageMetadataService; +import gg.modl.backend.storage.service.StorageQuotaService; +import java.io.IOException; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class IconUploadService { + private final S3StorageService s3StorageService; + private final StorageQuotaService storageQuotaService; + private final StorageMetadataService storageMetadataService; + private static final Set ALLOWED_IMAGE_TYPES = Set.of( + "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp" + ); + private static final long MAX_ICON_SIZE = 2 * 1024 * 1024; + + public ResponseEntity uploadIcon(Server server, MultipartFile file, String iconType) { + ResponseEntity validationError = validateUpload(server, file, iconType); + if (validationError != null) { + return validationError; + } + + try { + String fileName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "icon"; + S3StorageService.UploadFileResult result = s3StorageService.uploadFile( + server, "icons/" + iconType, fileName, file.getContentType(), file.getBytes() + ); + StorageQuotaService.ConfirmResult confirmResult = storageQuotaService.confirmAndRecordFile( + server, result.key(), file.getSize(), file.getContentType() + ); + switch (confirmResult) { + case QUOTA_EXCEEDED -> { + s3StorageService.deleteFile(result.key()); + return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); + } + case RECORD_FAILED -> { + s3StorageService.deleteFile(result.key()); + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to record upload")); + } + default -> { + } + } + return ResponseEntity.ok(Map.of("url", result.cdnUrl())); + } catch (IOException e) { + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to read file")); + } catch (RuntimeException e) { + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to upload file")); + } + } + + private ResponseEntity validateUpload(Server server, MultipartFile file, String iconType) { + if (!iconType.equals("homepage") && !iconType.equals("panel")) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid icon type. Must be 'homepage' or 'panel'.")); + } + if (file.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "No file uploaded")); + } + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_IMAGE_TYPES.contains(contentType)) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid file type. Allowed: PNG, JPEG, GIF, WebP")); + } + if (file.getSize() > MAX_ICON_SIZE) { + return ResponseEntity.badRequest().body(Map.of("error", "File too large. Maximum size is 2MB.")); + } + if (!s3StorageService.isConfigured()) { + return ResponseEntity.status(503).body(Map.of("error", "File storage is not configured")); + } + if (!storageQuotaService.canUpload(server, file.getSize())) { + return ResponseEntity.badRequest().body(Map.of("error", "Storage quota exceeded")); + } + return null; + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java b/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java index ba25880..a358644 100644 --- a/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java @@ -1,124 +1,124 @@ -package gg.modl.backend.settings.service; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.OffenderThresholdSettings; -import java.time.Duration; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class OffenderThresholdSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; - private static final String SETTINGS_TYPE_STATUS_THRESHOLDS = "statusThresholds"; - - private final Cache thresholdCache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofSeconds(45)) - .maximumSize(500) - .build(); - private static final int MIN_THRESHOLD = 0; - private static final int MAX_THRESHOLD = 10_000; - private static final int MIN_POINT_EXPIRY_MONTHS = 1; - private static final int MAX_POINT_EXPIRY_MONTHS = 60; - - public OffenderThresholdSettings getThresholdSettings(Server server) { - return thresholdCache.get(server.getId(), id -> getThresholdSettingsState(server).data()); - } - - public VersionedSettings getThresholdSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_STATUS_THRESHOLDS); - OffenderThresholdSettings settings = mapToThresholdSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); - } - - public VersionedSettings patchThresholdSettings( - Server server, - long expectedVersion, - OffenderThresholdSettings patch - ) { - OffenderThresholdSettings current = getThresholdSettingsState(server).data(); - if (patch != null) { - if (patch.getSocial() != null) { - current.setSocial(sanitizeCategoryThresholds(patch.getSocial())); - } - if (patch.getGameplay() != null) { - current.setGameplay(sanitizeCategoryThresholds(patch.getGameplay())); - } - } - - current = normalizeSettings(current); - - Map data = codec().encode(current); - SettingsDocumentService.RawSettingsState updated; - try { - updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_STATUS_THRESHOLDS, - expectedVersion, - new LinkedHashMap<>(data) - ); - } finally { - thresholdCache.invalidate(server.getId()); - } - return new VersionedSettings<>(mapToThresholdSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - public OffenderThresholdSettings updateThresholdSettings(Server server, OffenderThresholdSettings newSettings) { - long expectedVersion = getThresholdSettingsState(server).version(); - return patchThresholdSettings(server, expectedVersion, newSettings).data(); - } - - private OffenderThresholdSettings mapToThresholdSettings(Map data) { - OffenderThresholdSettings mapped = codec().decode(data); - if (mapped.getSocial() == null || mapped.getGameplay() == null) { - return OffenderThresholdSettings.defaults(); - } - return normalizeSettings(mapped); - } - - private SettingsCodec codec() { - return SettingsCodec.of(objectMapper, OffenderThresholdSettings.class, OffenderThresholdSettings::defaults); - } - - private OffenderThresholdSettings normalizeSettings(OffenderThresholdSettings settings) { - OffenderThresholdSettings normalized = settings != null ? settings : OffenderThresholdSettings.defaults(); - if (normalized.getSocial() == null) { - normalized.setSocial(OffenderThresholdSettings.defaults().getSocial()); - } - if (normalized.getGameplay() == null) { - normalized.setGameplay(OffenderThresholdSettings.defaults().getGameplay()); - } - - normalized.setSocial(sanitizeCategoryThresholds(normalized.getSocial())); - normalized.setGameplay(sanitizeCategoryThresholds(normalized.getGameplay())); - return normalized; - } - - private OffenderThresholdSettings.CategoryThresholds sanitizeCategoryThresholds( - OffenderThresholdSettings.CategoryThresholds thresholds - ) { - int medium = sanitizeThresholdValue(thresholds.getMedium()); - int habitual = sanitizeThresholdValue(thresholds.getHabitual()); - int pointExpiryMonths = sanitizePointExpiryMonths(thresholds.getPointExpiryMonths()); - if (habitual < medium) { - habitual = medium; - } - return new OffenderThresholdSettings.CategoryThresholds(medium, habitual, pointExpiryMonths); - } - - private int sanitizeThresholdValue(int value) { - return Math.max(MIN_THRESHOLD, Math.min(MAX_THRESHOLD, value)); - } - - private int sanitizePointExpiryMonths(int value) { - return Math.max(MIN_POINT_EXPIRY_MONTHS, Math.min(MAX_POINT_EXPIRY_MONTHS, value)); - } -} +package gg.modl.backend.settings.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.OffenderThresholdSettings; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class OffenderThresholdSettingsService { + private final SettingsDocumentService settingsDocumentService; + private final ObjectMapper objectMapper; + private static final String SETTINGS_TYPE_STATUS_THRESHOLDS = "statusThresholds"; + + private final Cache thresholdCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(45)) + .maximumSize(500) + .build(); + private static final int MIN_THRESHOLD = 0; + private static final int MAX_THRESHOLD = 10_000; + private static final int MIN_POINT_EXPIRY_MONTHS = 1; + private static final int MAX_POINT_EXPIRY_MONTHS = 60; + + public OffenderThresholdSettings getThresholdSettings(Server server) { + return thresholdCache.get(server.getId(), id -> getThresholdSettingsState(server).data()); + } + + public VersionedSettings getThresholdSettingsState(Server server) { + SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_STATUS_THRESHOLDS); + OffenderThresholdSettings settings = mapToThresholdSettings(state.data()); + return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + } + + public VersionedSettings patchThresholdSettings( + Server server, + long expectedVersion, + OffenderThresholdSettings patch + ) { + OffenderThresholdSettings current = getThresholdSettingsState(server).data(); + if (patch != null) { + if (patch.getSocial() != null) { + current.setSocial(sanitizeCategoryThresholds(patch.getSocial())); + } + if (patch.getGameplay() != null) { + current.setGameplay(sanitizeCategoryThresholds(patch.getGameplay())); + } + } + + current = normalizeSettings(current); + + Map data = codec().encode(current); + SettingsDocumentService.RawSettingsState updated; + try { + updated = settingsDocumentService.saveRawState( + server, + SETTINGS_TYPE_STATUS_THRESHOLDS, + expectedVersion, + new LinkedHashMap<>(data) + ); + } finally { + thresholdCache.invalidate(server.getId()); + } + return new VersionedSettings<>(mapToThresholdSettings(updated.data()), updated.version(), updated.updatedAt()); + } + + public OffenderThresholdSettings updateThresholdSettings(Server server, OffenderThresholdSettings newSettings) { + long expectedVersion = getThresholdSettingsState(server).version(); + return patchThresholdSettings(server, expectedVersion, newSettings).data(); + } + + private OffenderThresholdSettings mapToThresholdSettings(Map data) { + OffenderThresholdSettings mapped = codec().decode(data); + if (mapped.getSocial() == null || mapped.getGameplay() == null) { + return OffenderThresholdSettings.defaults(); + } + return normalizeSettings(mapped); + } + + private SettingsCodec codec() { + return SettingsCodec.of(objectMapper, OffenderThresholdSettings.class, OffenderThresholdSettings::defaults); + } + + private OffenderThresholdSettings normalizeSettings(OffenderThresholdSettings settings) { + OffenderThresholdSettings normalized = settings != null ? settings : OffenderThresholdSettings.defaults(); + if (normalized.getSocial() == null) { + normalized.setSocial(OffenderThresholdSettings.defaults().getSocial()); + } + if (normalized.getGameplay() == null) { + normalized.setGameplay(OffenderThresholdSettings.defaults().getGameplay()); + } + + normalized.setSocial(sanitizeCategoryThresholds(normalized.getSocial())); + normalized.setGameplay(sanitizeCategoryThresholds(normalized.getGameplay())); + return normalized; + } + + private OffenderThresholdSettings.CategoryThresholds sanitizeCategoryThresholds( + OffenderThresholdSettings.CategoryThresholds thresholds + ) { + int medium = sanitizeThresholdValue(thresholds.getMedium()); + int habitual = sanitizeThresholdValue(thresholds.getHabitual()); + int pointExpiryMonths = sanitizePointExpiryMonths(thresholds.getPointExpiryMonths()); + if (habitual < medium) { + habitual = medium; + } + return new OffenderThresholdSettings.CategoryThresholds(medium, habitual, pointExpiryMonths); + } + + private int sanitizeThresholdValue(int value) { + return Math.max(MIN_THRESHOLD, Math.min(MAX_THRESHOLD, value)); + } + + private int sanitizePointExpiryMonths(int value) { + return Math.max(MIN_POINT_EXPIRY_MONTHS, Math.min(MAX_POINT_EXPIRY_MONTHS, value)); + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java b/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java index 7fe269a..810c1b3 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsConflictException.java @@ -1,14 +1,14 @@ -package gg.modl.backend.settings.service; - -import gg.modl.backend.infrastructure.exception.ConflictException; -import lombok.Getter; - -@Getter -public class SettingsConflictException extends ConflictException { - private final long currentVersion; - - public SettingsConflictException(String message, long currentVersion) { - super(message); - this.currentVersion = currentVersion; - } -} +package gg.modl.backend.settings.service; + +import gg.modl.backend.infrastructure.exception.ConflictException; +import lombok.Getter; + +@Getter +public class SettingsConflictException extends ConflictException { + private final long currentVersion; + + public SettingsConflictException(String message, long currentVersion) { + super(message); + this.currentVersion = currentVersion; + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java b/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java index c819968..46f51d1 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java @@ -1,104 +1,104 @@ -package gg.modl.backend.settings.service; - +package gg.modl.backend.settings.service; + import gg.modl.backend.database.mongo.repository.SettingsMongoRepository; import gg.modl.backend.infrastructure.util.MongoKeyUtils; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; -import java.util.Date; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class SettingsDocumentService { - private final SettingsMongoRepository settingsRepository; - private static final long INITIAL_VERSION = 0L; - - public RawSettingsState saveRawState(Server server, String type, long expectedVersion, Map data) { - Settings current = findLatestSettingsDocument(server, type); - RawSettingsState currentState = toRawState(current); - - if (currentState.version() != expectedVersion) { - throwConflict(currentState.version()); - } - +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.Settings; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SettingsDocumentService { + private final SettingsMongoRepository settingsRepository; + private static final long INITIAL_VERSION = 0L; + + public RawSettingsState saveRawState(Server server, String type, long expectedVersion, Map data) { + Settings current = findLatestSettingsDocument(server, type); + RawSettingsState currentState = toRawState(current); + + if (currentState.version() != expectedVersion) { + throwConflict(currentState.version()); + } + Map normalizedData = data == null ? new LinkedHashMap<>() : MongoKeyUtils.sanitizeKeys(data); - Date now = new Date(); - - if (!currentState.exists()) { - try { - Settings inserted = new Settings(null, type, normalizedData, expectedVersion + 1, now); - settingsRepository.saveEntity(server, inserted); - return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); - } catch (org.springframework.dao.DuplicateKeyException duplicateKeyException) { - throwConflict(getRawState(server, type).version()); - } - } - - if (currentState.data().equals(normalizedData)) { - return currentState; - } - - boolean updated = settingsRepository.updateWithVersionCheck( - server, current.getId(), expectedVersion, type, normalizedData, expectedVersion + 1, now); - if (!updated) { - throwConflict(getRawState(server, type).version()); - } - - return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); - } - - public RawSettingsState getRawState(Server server, String type) { - Settings settings = findLatestSettingsDocument(server, type); - return toRawState(settings); - } - - private void throwConflict(long currentVersion) { - throw new SettingsConflictException( - "Settings were modified by another user. Reload and retry.", - currentVersion - ); - } - - private Settings findLatestSettingsDocument(Server server, String type) { - List matches = settingsRepository.findLatestByType(server, type, 2); - if (matches.size() > 1) { - log.warn( - "Detected duplicate settings documents for type '{}'. Using latest id '{}'.", - type, - matches.get(0).getId() - ); - } - return matches.isEmpty() ? null : matches.get(0); - } - - @SuppressWarnings("unchecked") - private RawSettingsState toRawState(Settings settings) { - if (settings == null) { - return new RawSettingsState(new LinkedHashMap<>(), INITIAL_VERSION, null, false); - } - - Map mappedData = new LinkedHashMap<>(); - if (settings.getData() instanceof Map rawMap) { - mappedData.putAll((Map) rawMap); - } - - long version = settings.getVersion() != null ? settings.getVersion() : INITIAL_VERSION; - return new RawSettingsState(mappedData, version, settings.getUpdatedAt(), true); - } - - public record RawSettingsState( - Map data, - long version, - Date updatedAt, - boolean exists - ) { - } -} + Date now = new Date(); + + if (!currentState.exists()) { + try { + Settings inserted = new Settings(null, type, normalizedData, expectedVersion + 1, now); + settingsRepository.saveEntity(server, inserted); + return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); + } catch (org.springframework.dao.DuplicateKeyException duplicateKeyException) { + throwConflict(getRawState(server, type).version()); + } + } + + if (currentState.data().equals(normalizedData)) { + return currentState; + } + + boolean updated = settingsRepository.updateWithVersionCheck( + server, current.getId(), expectedVersion, type, normalizedData, expectedVersion + 1, now); + if (!updated) { + throwConflict(getRawState(server, type).version()); + } + + return new RawSettingsState(normalizedData, expectedVersion + 1, now, true); + } + + public RawSettingsState getRawState(Server server, String type) { + Settings settings = findLatestSettingsDocument(server, type); + return toRawState(settings); + } + + private void throwConflict(long currentVersion) { + throw new SettingsConflictException( + "Settings were modified by another user. Reload and retry.", + currentVersion + ); + } + + private Settings findLatestSettingsDocument(Server server, String type) { + List matches = settingsRepository.findLatestByType(server, type, 2); + if (matches.size() > 1) { + log.warn( + "Detected duplicate settings documents for type '{}'. Using latest id '{}'.", + type, + matches.get(0).getId() + ); + } + return matches.isEmpty() ? null : matches.get(0); + } + + @SuppressWarnings("unchecked") + private RawSettingsState toRawState(Settings settings) { + if (settings == null) { + return new RawSettingsState(new LinkedHashMap<>(), INITIAL_VERSION, null, false); + } + + Map mappedData = new LinkedHashMap<>(); + if (settings.getData() instanceof Map rawMap) { + mappedData.putAll((Map) rawMap); + } + + long version = settings.getVersion() != null ? settings.getVersion() : INITIAL_VERSION; + return new RawSettingsState(mappedData, version, settings.getUpdatedAt(), true); + } + + public record RawSettingsState( + Map data, + long version, + Date updatedAt, + boolean exists + ) { + } +} diff --git a/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java b/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java index 64c2e0a..bc74370 100644 --- a/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java @@ -1,168 +1,168 @@ -package gg.modl.backend.settings.service; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.TicketFormSettings; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class TicketFormSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; - private static final String SETTINGS_TYPE_TICKET_FORMS = "ticketForms"; - - public VersionedSettings patchTicketFormSettings( - Server server, - long expectedVersion, - TicketFormSettings newSettings - ) { - TicketFormSettings merged = newSettings != null ? newSettings : getDefaultTicketFormSettings(); - ensureFormDefaults(merged); - Map data = codec().encode(merged); - - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_TICKET_FORMS, - expectedVersion, - new LinkedHashMap<>(data) - ); - - return new VersionedSettings<>(mapToTicketFormSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - public TicketFormSettings updateTicketFormSettings(Server server, TicketFormSettings newSettings) { - long expectedVersion = getTicketFormSettingsState(server).version(); - return patchTicketFormSettings(server, expectedVersion, newSettings).data(); - } - - public TicketFormSettings.TicketForm getFormByType(Server server, String formType) { - TicketFormSettings settings = getTicketFormSettings(server); - - return switch (formType.toLowerCase()) { - case "bug" -> settings.getBug(); - case "support" -> settings.getSupport(); - case "application", "staff" -> settings.getApplication(); - case "player" -> settings.getPlayer(); - case "chat" -> settings.getChat(); - default -> null; - }; - } - - public TicketFormSettings getTicketFormSettings(Server server) { - return getTicketFormSettingsState(server).data(); - } - - public VersionedSettings getTicketFormSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_TICKET_FORMS); - TicketFormSettings settings = mapToTicketFormSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); - } - - private TicketFormSettings mapToTicketFormSettings(Map data) { - TicketFormSettings mapped = codec().decode(data); - ensureFormDefaults(mapped); - return mapped; - } - - private SettingsCodec codec() { - return SettingsCodec.of(objectMapper, TicketFormSettings.class, this::getDefaultTicketFormSettings); - } - - private void ensureFormDefaults(TicketFormSettings settings) { - if (settings.getBug() == null) { - settings.setBug(emptyForm()); - } - if (settings.getSupport() == null) { - settings.setSupport(emptyForm()); - } - if (settings.getApplication() == null) { - settings.setApplication(emptyForm()); - } - if (settings.getPlayer() == null) { - settings.setPlayer(emptyForm()); - } - if (settings.getChat() == null) { - settings.setChat(emptyForm()); - } - - for (TicketFormSettings.TicketForm form : List.of( - settings.getBug(), settings.getSupport(), settings.getApplication(), - settings.getPlayer(), settings.getChat() - )) { - sanitizeForm(form); - } - } - - private void sanitizeForm(TicketFormSettings.TicketForm form) { - if (form.getFields() == null) { - form.setFields(new ArrayList<>()); - } - if (form.getSections() == null) { - form.setSections(new ArrayList<>()); - } - if (form.getAllowEmailNotifications() == null) { - form.setAllowEmailNotifications(true); - } - } - - private TicketFormSettings getDefaultTicketFormSettings() { - return TicketFormSettings.builder() - .bug(emptyForm()) - .support(emptyForm()) - .application(emptyForm()) - .player(emptyForm()) - .chat(emptyForm()) - .build(); - } - - private TicketFormSettings.TicketForm emptyForm() { - return TicketFormSettings.TicketForm.builder() - .allowEmailNotifications(true) - .fields(new ArrayList<>()) - .sections(new ArrayList<>()) - .build(); - } - - public TicketFormSettings updateFormByType(Server server, String formType, TicketFormSettings.TicketForm form) { - TicketFormSettings settings = getTicketFormSettings(server); - - switch (formType.toLowerCase()) { - case "bug" -> settings.setBug(form); - case "support" -> settings.setSupport(form); - case "application", "staff" -> settings.setApplication(form); - case "player" -> settings.setPlayer(form); - case "chat" -> settings.setChat(form); - default -> { - // no-op for unknown form type - } - } - - return updateTicketFormSettings(server, settings); - } - - public Map buildTicketFormsResponse(TicketFormSettings ticketForms) { - Map forms = new HashMap<>(); - putFormIfNotNull(forms, "bug", ticketForms.getBug()); - putFormIfNotNull(forms, "support", ticketForms.getSupport()); - putFormIfNotNull(forms, "application", ticketForms.getApplication()); - putFormIfNotNull(forms, "player", ticketForms.getPlayer()); - putFormIfNotNull(forms, "chat", ticketForms.getChat()); - return forms; - } - - private void putFormIfNotNull(Map forms, String key, TicketFormSettings.TicketForm form) { - if (form != null) { - forms.put(key, objectMapper.convertValue(form, new TypeReference>() {})); - } - } -} +package gg.modl.backend.settings.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.TicketFormSettings; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TicketFormSettingsService { + private final SettingsDocumentService settingsDocumentService; + private final ObjectMapper objectMapper; + private static final String SETTINGS_TYPE_TICKET_FORMS = "ticketForms"; + + public VersionedSettings patchTicketFormSettings( + Server server, + long expectedVersion, + TicketFormSettings newSettings + ) { + TicketFormSettings merged = newSettings != null ? newSettings : getDefaultTicketFormSettings(); + ensureFormDefaults(merged); + Map data = codec().encode(merged); + + SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( + server, + SETTINGS_TYPE_TICKET_FORMS, + expectedVersion, + new LinkedHashMap<>(data) + ); + + return new VersionedSettings<>(mapToTicketFormSettings(updated.data()), updated.version(), updated.updatedAt()); + } + + public TicketFormSettings updateTicketFormSettings(Server server, TicketFormSettings newSettings) { + long expectedVersion = getTicketFormSettingsState(server).version(); + return patchTicketFormSettings(server, expectedVersion, newSettings).data(); + } + + public TicketFormSettings.TicketForm getFormByType(Server server, String formType) { + TicketFormSettings settings = getTicketFormSettings(server); + + return switch (formType.toLowerCase()) { + case "bug" -> settings.getBug(); + case "support" -> settings.getSupport(); + case "application", "staff" -> settings.getApplication(); + case "player" -> settings.getPlayer(); + case "chat" -> settings.getChat(); + default -> null; + }; + } + + public TicketFormSettings getTicketFormSettings(Server server) { + return getTicketFormSettingsState(server).data(); + } + + public VersionedSettings getTicketFormSettingsState(Server server) { + SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_TICKET_FORMS); + TicketFormSettings settings = mapToTicketFormSettings(state.data()); + return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + } + + private TicketFormSettings mapToTicketFormSettings(Map data) { + TicketFormSettings mapped = codec().decode(data); + ensureFormDefaults(mapped); + return mapped; + } + + private SettingsCodec codec() { + return SettingsCodec.of(objectMapper, TicketFormSettings.class, this::getDefaultTicketFormSettings); + } + + private void ensureFormDefaults(TicketFormSettings settings) { + if (settings.getBug() == null) { + settings.setBug(emptyForm()); + } + if (settings.getSupport() == null) { + settings.setSupport(emptyForm()); + } + if (settings.getApplication() == null) { + settings.setApplication(emptyForm()); + } + if (settings.getPlayer() == null) { + settings.setPlayer(emptyForm()); + } + if (settings.getChat() == null) { + settings.setChat(emptyForm()); + } + + for (TicketFormSettings.TicketForm form : List.of( + settings.getBug(), settings.getSupport(), settings.getApplication(), + settings.getPlayer(), settings.getChat() + )) { + sanitizeForm(form); + } + } + + private void sanitizeForm(TicketFormSettings.TicketForm form) { + if (form.getFields() == null) { + form.setFields(new ArrayList<>()); + } + if (form.getSections() == null) { + form.setSections(new ArrayList<>()); + } + if (form.getAllowEmailNotifications() == null) { + form.setAllowEmailNotifications(true); + } + } + + private TicketFormSettings getDefaultTicketFormSettings() { + return TicketFormSettings.builder() + .bug(emptyForm()) + .support(emptyForm()) + .application(emptyForm()) + .player(emptyForm()) + .chat(emptyForm()) + .build(); + } + + private TicketFormSettings.TicketForm emptyForm() { + return TicketFormSettings.TicketForm.builder() + .allowEmailNotifications(true) + .fields(new ArrayList<>()) + .sections(new ArrayList<>()) + .build(); + } + + public TicketFormSettings updateFormByType(Server server, String formType, TicketFormSettings.TicketForm form) { + TicketFormSettings settings = getTicketFormSettings(server); + + switch (formType.toLowerCase()) { + case "bug" -> settings.setBug(form); + case "support" -> settings.setSupport(form); + case "application", "staff" -> settings.setApplication(form); + case "player" -> settings.setPlayer(form); + case "chat" -> settings.setChat(form); + default -> { + // no-op for unknown form type + } + } + + return updateTicketFormSettings(server, settings); + } + + public Map buildTicketFormsResponse(TicketFormSettings ticketForms) { + Map forms = new HashMap<>(); + putFormIfNotNull(forms, "bug", ticketForms.getBug()); + putFormIfNotNull(forms, "support", ticketForms.getSupport()); + putFormIfNotNull(forms, "application", ticketForms.getApplication()); + putFormIfNotNull(forms, "player", ticketForms.getPlayer()); + putFormIfNotNull(forms, "chat", ticketForms.getChat()); + return forms; + } + + private void putFormIfNotNull(Map forms, String key, TicketFormSettings.TicketForm form) { + if (form != null) { + forms.put(key, objectMapper.convertValue(form, new TypeReference>() {})); + } + } +} diff --git a/src/main/java/gg/modl/backend/staff/controller/MinecraftStaffController.java b/src/main/java/gg/modl/backend/staff/controller/MinecraftStaffController.java index 7833071..3185b99 100644 --- a/src/main/java/gg/modl/backend/staff/controller/MinecraftStaffController.java +++ b/src/main/java/gg/modl/backend/staff/controller/MinecraftStaffController.java @@ -1,111 +1,111 @@ -package gg.modl.backend.staff.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.dto.response.MinecraftStaffPermissionsResponse; -import gg.modl.backend.staff.dto.response.MinecraftStaffSummaryResponse; -import gg.modl.backend.staff.service.StaffService; -import jakarta.servlet.http.HttpServletRequest; -import gg.modl.backend.infrastructure.validation.RegExpConstants; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -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; - -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_STAFF) -@RequiredArgsConstructor -public class MinecraftStaffController { - private final StaffService staffService; - private final RoleAuthorization roleAuthorization; - - @GetMapping - public ResponseEntity> getAllStaff(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List staffList = staffService.getMinecraftStaffSummary(server); - - return ResponseEntity.ok(Map.of( - "status", 200, - "staff", staffList - )); - } - - @GetMapping("/permissions") - public ResponseEntity> getStaffPermissions(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - List staffList = staffService.getMinecraftStaffPermissions(server); - - return ResponseEntity.ok(Map.of( - "status", 200, - "data", Map.of("staff", staffList) - )); - } - - @PatchMapping("/{id}/role") - public ResponseEntity> updateStaffRole( - @PathVariable String id, - @RequestBody @Valid UpdateRoleRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - String actingStaffId = RequestUtil.getActingStaffId(httpRequest); - RoleAuthorization.PerformerAuthority performer = roleAuthorization.minecraftPerformer(server, actingStaffId); - - if (!staffService.updateMinecraftStaffRole(server, id, request.role(), performer)) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Staff member not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true - )); - } - - @PostMapping("/disconnect") - public ResponseEntity> staffDisconnect( - @RequestBody @Valid StaffDisconnectRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - - if (!staffService.markStaffDisconnected(server, request.minecraftUuid())) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Staff member not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true - )); - } - - public record UpdateRoleRequest( - @NotBlank @Size(max = RequestValidationLimits.STAFF_ROLE_MAX_LENGTH) String role - ) {} - - public record StaffDisconnectRequest( - @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, - @Min(0) long sessionDurationMs - ) {} -} +package gg.modl.backend.staff.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.dto.response.MinecraftStaffPermissionsResponse; +import gg.modl.backend.staff.dto.response.MinecraftStaffSummaryResponse; +import gg.modl.backend.staff.service.StaffService; +import jakarta.servlet.http.HttpServletRequest; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_STAFF) +@RequiredArgsConstructor +public class MinecraftStaffController { + private final StaffService staffService; + private final RoleAuthorization roleAuthorization; + + @GetMapping + public ResponseEntity> getAllStaff(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List staffList = staffService.getMinecraftStaffSummary(server); + + return ResponseEntity.ok(Map.of( + "status", 200, + "staff", staffList + )); + } + + @GetMapping("/permissions") + public ResponseEntity> getStaffPermissions(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + List staffList = staffService.getMinecraftStaffPermissions(server); + + return ResponseEntity.ok(Map.of( + "status", 200, + "data", Map.of("staff", staffList) + )); + } + + @PatchMapping("/{id}/role") + public ResponseEntity> updateStaffRole( + @PathVariable String id, + @RequestBody @Valid UpdateRoleRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + String actingStaffId = RequestUtil.getActingStaffId(httpRequest); + RoleAuthorization.PerformerAuthority performer = roleAuthorization.minecraftPerformer(server, actingStaffId); + + if (!staffService.updateMinecraftStaffRole(server, id, request.role(), performer)) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Staff member not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true + )); + } + + @PostMapping("/disconnect") + public ResponseEntity> staffDisconnect( + @RequestBody @Valid StaffDisconnectRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + + if (!staffService.markStaffDisconnected(server, request.minecraftUuid())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Staff member not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true + )); + } + + public record UpdateRoleRequest( + @NotBlank @Size(max = RequestValidationLimits.STAFF_ROLE_MAX_LENGTH) String role + ) {} + + public record StaffDisconnectRequest( + @NotBlank @Pattern(regexp = RegExpConstants.UUID) String minecraftUuid, + @Min(0) long sessionDurationMs + ) {} +} diff --git a/src/main/java/gg/modl/backend/staff/controller/PanelStaffController.java b/src/main/java/gg/modl/backend/staff/controller/PanelStaffController.java index aedcd38..98c780d 100644 --- a/src/main/java/gg/modl/backend/staff/controller/PanelStaffController.java +++ b/src/main/java/gg/modl/backend/staff/controller/PanelStaffController.java @@ -1,233 +1,233 @@ -package gg.modl.backend.staff.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.log.service.PanelActionAuditor; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.dto.request.AssignMinecraftPlayerRequest; -import gg.modl.backend.staff.dto.request.CreateStaffRequest; -import gg.modl.backend.staff.dto.request.InviteStaffRequest; -import gg.modl.backend.staff.dto.request.UpdateStaffRequest; -import gg.modl.backend.staff.dto.response.InviteResultResponse; -import gg.modl.backend.staff.dto.response.StaffResponse; -import gg.modl.backend.staff.service.InvitationService; -import gg.modl.backend.staff.service.StaffService; -import gg.modl.proto.modl.v1.AvailablePlayersResponse; -import gg.modl.proto.modl.v1.CheckUsernameResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.PanelStaffListResponse; -import gg.modl.proto.modl.v1.StaffMutationResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_STAFF) -@RequiredArgsConstructor -public class PanelStaffController { - private final StaffService staffService; - private final InvitationService invitationService; - private final RoleAuthorization roleAuthorization; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PanelActionAuditor panelActionAuditor; - private final Validator validator; - - @GetMapping - public ResponseEntity getAllStaff(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(PanelStaffProtoMapper.toStaffListResponse(staffService.getAllStaff(server))); - } - - @GetMapping("/check-username/{username}") - public ResponseEntity checkUsername( - @PathVariable String username, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - boolean exists = staffService.checkUsernameExists(server, username); - return ResponseEntity.ok(PanelStaffProtoMapper.toCheckUsernameResponse(exists)); - } - - @GetMapping("/{username}") - public ResponseEntity getStaffByUsername( - @PathVariable String username, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - return staffService.getStaffByUsername(server, username) - .map(PanelStaffProtoMapper::toStaffResponse) - .map(ResponseEntity::ok) - .orElse(ResponseEntity.notFound().build()); - } - - @PostMapping - public ResponseEntity createStaff( - @RequestBody gg.modl.proto.modl.v1.CreateStaffRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - CreateStaffRequest mappedRequest = PanelStaffProtoMapper.toCreateStaffRequest(createRequest); - validate(mappedRequest); - StaffResponse staff = staffService.createStaff(server, mappedRequest, performer); - invalidateStaff(server); - return ResponseEntity.status(HttpStatus.CREATED).body(PanelStaffProtoMapper.toStaffResponse(staff)); - } - - @PatchMapping("/{username}") - public ResponseEntity updateStaff( - @PathVariable String username, - @RequestBody gg.modl.proto.modl.v1.UpdateStaffRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - UpdateStaffRequest mappedRequest = PanelStaffProtoMapper.toUpdateStaffRequest(updateRequest); - return staffService.updateStaff(server, username, mappedRequest) - .map(staff -> { - invalidateStaff(server); - return ResponseEntity.ok(PanelStaffProtoMapper.toStaffResponse(staff)); - }) - .orElse(ResponseEntity.notFound().build()); - } - - @PatchMapping("/{id}/role") - public ResponseEntity updateStaffRole( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.UpdateStaffRoleRequest roleRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - return staffService.updateStaffRole(server, id, roleRequest.getRole(), performer) - .map(staff -> { - invalidateStaff(server); - return ResponseEntity.ok( - PanelStaffProtoMapper.toStaffMutationResponse("Role updated successfully.", staff)); - }) - .orElse(ResponseEntity.notFound().build()); - } - - @DeleteMapping("/{id}") - public ResponseEntity deleteStaff( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - boolean deleted = staffService.deleteStaff(server, id, performer); - if (deleted) { - invalidateStaff(server); - return ResponseEntity.ok( - PanelStaffProtoMapper.toStaffMutationResponse("Removed successfully.", null)); - } - return ResponseEntity.notFound().build(); - } - - @PostMapping("/invite") - public ResponseEntity inviteStaff( - @RequestBody gg.modl.proto.modl.v1.InviteStaffRequest inviteRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String inviterEmail = RequestUtil.getSessionEmail(request); - RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, inviterEmail); - - InviteStaffRequest mappedRequest = PanelStaffProtoMapper.toInviteStaffRequest(inviteRequest); - InviteResultResponse result = invitationService.sendInvitations(server, mappedRequest, performer); - - if (result.success().isEmpty()) { - return ResponseEntity.badRequest().body(PanelStaffProtoMapper.toInviteResultResponse(result)); - } - - invalidateStaff(server); - panelActionAuditor.recordStaffAction(server, inviterEmail, "Invited staff: " + String.join(", ", result.success())); - if (result.failed().isEmpty()) { - return ResponseEntity.status(HttpStatus.CREATED) - .body(gg.modl.proto.modl.v1.InviteResultResponse.newBuilder() - .setMessage(result.message()) - .build()); - } - return ResponseEntity.status(HttpStatus.MULTI_STATUS) - .body(PanelStaffProtoMapper.toInviteResultResponse(result)); - } - - @PostMapping("/invitations/{id}/resend") - public ResponseEntity resendInvitation( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - boolean resent = invitationService.resendInvitation(server, id); - if (resent) { - invalidateStaff(server); - return ResponseEntity.ok( - PanelStaffProtoMapper.toStaffMutationResponse("Invitation resent successfully", null)); - } - return ResponseEntity.notFound().build(); - } - - @PatchMapping("/{email}/minecraft-player") - public ResponseEntity assignMinecraftPlayer( - @PathVariable String email, - @RequestBody gg.modl.proto.modl.v1.AssignMinecraftPlayerRequest assignRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - RoleAuthorization.PerformerAuthority performer = - roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); - - boolean clearing = !assignRequest.hasMinecraftUuid() && !assignRequest.hasMinecraftUsername(); - AssignMinecraftPlayerRequest mappedRequest = PanelStaffProtoMapper.toAssignMinecraftPlayerRequest(assignRequest); - return staffService.assignMinecraftPlayer(server, email, mappedRequest, performer) - .map(staff -> { - invalidateStaff(server); - String message = clearing - ? "Minecraft player assignment cleared successfully" - : "Minecraft player assigned successfully"; - return ResponseEntity.ok(PanelStaffProtoMapper.toStaffMutationResponse(message, staff)); - }) - .orElse(ResponseEntity.notFound().build()); - } - - @GetMapping("/available-players") - public ResponseEntity getAvailablePlayers(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok( - PanelStaffProtoMapper.toAvailablePlayersResponse(staffService.getAvailablePlayers(server))); - } - - private void invalidateStaff(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_STAFF); - } - - private void validate(T request) { - Set> violations = validator.validate(request); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } - } -} +package gg.modl.backend.staff.controller; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.log.service.PanelActionAuditor; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.dto.request.AssignMinecraftPlayerRequest; +import gg.modl.backend.staff.dto.request.CreateStaffRequest; +import gg.modl.backend.staff.dto.request.InviteStaffRequest; +import gg.modl.backend.staff.dto.request.UpdateStaffRequest; +import gg.modl.backend.staff.dto.response.InviteResultResponse; +import gg.modl.backend.staff.dto.response.StaffResponse; +import gg.modl.backend.staff.service.InvitationService; +import gg.modl.backend.staff.service.StaffService; +import gg.modl.proto.modl.v1.AvailablePlayersResponse; +import gg.modl.proto.modl.v1.CheckUsernameResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.PanelStaffListResponse; +import gg.modl.proto.modl.v1.StaffMutationResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_STAFF) +@RequiredArgsConstructor +public class PanelStaffController { + private final StaffService staffService; + private final InvitationService invitationService; + private final RoleAuthorization roleAuthorization; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PanelActionAuditor panelActionAuditor; + private final Validator validator; + + @GetMapping + public ResponseEntity getAllStaff(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(PanelStaffProtoMapper.toStaffListResponse(staffService.getAllStaff(server))); + } + + @GetMapping("/check-username/{username}") + public ResponseEntity checkUsername( + @PathVariable String username, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + boolean exists = staffService.checkUsernameExists(server, username); + return ResponseEntity.ok(PanelStaffProtoMapper.toCheckUsernameResponse(exists)); + } + + @GetMapping("/{username}") + public ResponseEntity getStaffByUsername( + @PathVariable String username, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + return staffService.getStaffByUsername(server, username) + .map(PanelStaffProtoMapper::toStaffResponse) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity createStaff( + @RequestBody gg.modl.proto.modl.v1.CreateStaffRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + CreateStaffRequest mappedRequest = PanelStaffProtoMapper.toCreateStaffRequest(createRequest); + validate(mappedRequest); + StaffResponse staff = staffService.createStaff(server, mappedRequest, performer); + invalidateStaff(server); + return ResponseEntity.status(HttpStatus.CREATED).body(PanelStaffProtoMapper.toStaffResponse(staff)); + } + + @PatchMapping("/{username}") + public ResponseEntity updateStaff( + @PathVariable String username, + @RequestBody gg.modl.proto.modl.v1.UpdateStaffRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + UpdateStaffRequest mappedRequest = PanelStaffProtoMapper.toUpdateStaffRequest(updateRequest); + return staffService.updateStaff(server, username, mappedRequest) + .map(staff -> { + invalidateStaff(server); + return ResponseEntity.ok(PanelStaffProtoMapper.toStaffResponse(staff)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @PatchMapping("/{id}/role") + public ResponseEntity updateStaffRole( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.UpdateStaffRoleRequest roleRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + return staffService.updateStaffRole(server, id, roleRequest.getRole(), performer) + .map(staff -> { + invalidateStaff(server); + return ResponseEntity.ok( + PanelStaffProtoMapper.toStaffMutationResponse("Role updated successfully.", staff)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteStaff( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + boolean deleted = staffService.deleteStaff(server, id, performer); + if (deleted) { + invalidateStaff(server); + return ResponseEntity.ok( + PanelStaffProtoMapper.toStaffMutationResponse("Removed successfully.", null)); + } + return ResponseEntity.notFound().build(); + } + + @PostMapping("/invite") + public ResponseEntity inviteStaff( + @RequestBody gg.modl.proto.modl.v1.InviteStaffRequest inviteRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String inviterEmail = RequestUtil.getSessionEmail(request); + RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, inviterEmail); + + InviteStaffRequest mappedRequest = PanelStaffProtoMapper.toInviteStaffRequest(inviteRequest); + InviteResultResponse result = invitationService.sendInvitations(server, mappedRequest, performer); + + if (result.success().isEmpty()) { + return ResponseEntity.badRequest().body(PanelStaffProtoMapper.toInviteResultResponse(result)); + } + + invalidateStaff(server); + panelActionAuditor.recordStaffAction(server, inviterEmail, "Invited staff: " + String.join(", ", result.success())); + if (result.failed().isEmpty()) { + return ResponseEntity.status(HttpStatus.CREATED) + .body(gg.modl.proto.modl.v1.InviteResultResponse.newBuilder() + .setMessage(result.message()) + .build()); + } + return ResponseEntity.status(HttpStatus.MULTI_STATUS) + .body(PanelStaffProtoMapper.toInviteResultResponse(result)); + } + + @PostMapping("/invitations/{id}/resend") + public ResponseEntity resendInvitation( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + boolean resent = invitationService.resendInvitation(server, id); + if (resent) { + invalidateStaff(server); + return ResponseEntity.ok( + PanelStaffProtoMapper.toStaffMutationResponse("Invitation resent successfully", null)); + } + return ResponseEntity.notFound().build(); + } + + @PatchMapping("/{email}/minecraft-player") + public ResponseEntity assignMinecraftPlayer( + @PathVariable String email, + @RequestBody gg.modl.proto.modl.v1.AssignMinecraftPlayerRequest assignRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + RoleAuthorization.PerformerAuthority performer = + roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); + + boolean clearing = !assignRequest.hasMinecraftUuid() && !assignRequest.hasMinecraftUsername(); + AssignMinecraftPlayerRequest mappedRequest = PanelStaffProtoMapper.toAssignMinecraftPlayerRequest(assignRequest); + return staffService.assignMinecraftPlayer(server, email, mappedRequest, performer) + .map(staff -> { + invalidateStaff(server); + String message = clearing + ? "Minecraft player assignment cleared successfully" + : "Minecraft player assigned successfully"; + return ResponseEntity.ok(PanelStaffProtoMapper.toStaffMutationResponse(message, staff)); + }) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/available-players") + public ResponseEntity getAvailablePlayers(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok( + PanelStaffProtoMapper.toAvailablePlayersResponse(staffService.getAvailablePlayers(server))); + } + + private void invalidateStaff(Server server) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_STAFF); + } + + private void validate(T request) { + Set> violations = validator.validate(request); + if (!violations.isEmpty()) { + throw new ValidationException(violations.iterator().next().getMessage()); + } + } +} diff --git a/src/main/java/gg/modl/backend/staff/controller/PublicStaffController.java b/src/main/java/gg/modl/backend/staff/controller/PublicStaffController.java index 151f912..ef1534a 100644 --- a/src/main/java/gg/modl/backend/staff/controller/PublicStaffController.java +++ b/src/main/java/gg/modl/backend/staff/controller/PublicStaffController.java @@ -1,99 +1,99 @@ -package gg.modl.backend.staff.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.dto.response.StaffResponse; -import gg.modl.backend.staff.service.InvitationService; -import gg.modl.backend.staff.service.StaffTwoFactorService; -import gg.modl.proto.modl.v1.AcceptInvitationResponse; -import gg.modl.proto.modl.v1.AcceptInvitationResponse.StaffInviteAcceptedMember; -import gg.modl.proto.modl.v1.Staff2faVerifyResponse; -import gg.modl.proto.modl.v1.SyncStaff2faVerification; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -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.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; - -@RestController -@RequestMapping("/v1/public/staff") -@RequiredArgsConstructor -public class PublicStaffController { - private final InvitationService invitationService; - private final StaffTwoFactorService staffTwoFactorService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping("/invitations/accept") - public ResponseEntity acceptInvitationGet( - @RequestParam(required = false) String token, - HttpServletRequest request - ) { - return acceptInvitationInternal(token, request); - } - - private ResponseEntity acceptInvitationInternal(String token, HttpServletRequest request) { - if (token == null || token.isBlank()) { - throw new ValidationException("Invalid invitation link."); - } - - Server server = RequestUtil.getRequestServer(request); - StaffResponse staff = invitationService.acceptInvitation(server, token); - - return ResponseEntity.ok(AcceptInvitationResponse.newBuilder() - .setMessage("Invitation accepted successfully.") - .setStaffMember(StaffInviteAcceptedMember.newBuilder() - .setEmail(nullToEmpty(staff.email())) - .setUsername(nullToEmpty(staff.username())) - .setRole(nullToEmpty(staff.role())) - .build()) - .build()); - } - - @PostMapping("/invitations/accept") - public ResponseEntity acceptInvitationPost( - @RequestParam(required = false) String token, - @RequestBody(required = false) gg.modl.proto.modl.v1.AcceptInvitationRequest body, - HttpServletRequest request - ) { - String resolvedToken = token; - if ((resolvedToken == null || resolvedToken.isBlank()) && body != null) { - resolvedToken = body.getToken(); - } - - return acceptInvitationInternal(resolvedToken, request); - } - - @PostMapping("/2fa/verify/{token}") - public ResponseEntity verify2faToken( - @PathVariable String token, - HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - String sessionEmail = RequestUtil.getSessionEmail(request); - - Optional verification = - staffTwoFactorService.verifyToken(server, token, sessionEmail); - if (verification.isEmpty()) { - return ResponseEntity.notFound().build(); - } - - verification.get().minecraftUuidOptional().ifPresent(minecraftUuid -> - realtimeEventPublisher.pushStaff2fa(server, List.of(SyncStaff2faVerification.newBuilder() - .setMinecraftUuid(minecraftUuid) - .build()), token)); - - return ResponseEntity.ok(Staff2faVerifyResponse.newBuilder().setStatus("verified").build()); - } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } -} +package gg.modl.backend.staff.controller; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.staff.dto.response.StaffResponse; +import gg.modl.backend.staff.service.InvitationService; +import gg.modl.backend.staff.service.StaffTwoFactorService; +import gg.modl.proto.modl.v1.AcceptInvitationResponse; +import gg.modl.proto.modl.v1.AcceptInvitationResponse.StaffInviteAcceptedMember; +import gg.modl.proto.modl.v1.Staff2faVerifyResponse; +import gg.modl.proto.modl.v1.SyncStaff2faVerification; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +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.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; + +@RestController +@RequestMapping("/v1/public/staff") +@RequiredArgsConstructor +public class PublicStaffController { + private final InvitationService invitationService; + private final StaffTwoFactorService staffTwoFactorService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping("/invitations/accept") + public ResponseEntity acceptInvitationGet( + @RequestParam(required = false) String token, + HttpServletRequest request + ) { + return acceptInvitationInternal(token, request); + } + + private ResponseEntity acceptInvitationInternal(String token, HttpServletRequest request) { + if (token == null || token.isBlank()) { + throw new ValidationException("Invalid invitation link."); + } + + Server server = RequestUtil.getRequestServer(request); + StaffResponse staff = invitationService.acceptInvitation(server, token); + + return ResponseEntity.ok(AcceptInvitationResponse.newBuilder() + .setMessage("Invitation accepted successfully.") + .setStaffMember(StaffInviteAcceptedMember.newBuilder() + .setEmail(nullToEmpty(staff.email())) + .setUsername(nullToEmpty(staff.username())) + .setRole(nullToEmpty(staff.role())) + .build()) + .build()); + } + + @PostMapping("/invitations/accept") + public ResponseEntity acceptInvitationPost( + @RequestParam(required = false) String token, + @RequestBody(required = false) gg.modl.proto.modl.v1.AcceptInvitationRequest body, + HttpServletRequest request + ) { + String resolvedToken = token; + if ((resolvedToken == null || resolvedToken.isBlank()) && body != null) { + resolvedToken = body.getToken(); + } + + return acceptInvitationInternal(resolvedToken, request); + } + + @PostMapping("/2fa/verify/{token}") + public ResponseEntity verify2faToken( + @PathVariable String token, + HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + String sessionEmail = RequestUtil.getSessionEmail(request); + + Optional verification = + staffTwoFactorService.verifyToken(server, token, sessionEmail); + if (verification.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + verification.get().minecraftUuidOptional().ifPresent(minecraftUuid -> + realtimeEventPublisher.pushStaff2fa(server, List.of(SyncStaff2faVerification.newBuilder() + .setMinecraftUuid(minecraftUuid) + .build()), token)); + + return ResponseEntity.ok(Staff2faVerifyResponse.newBuilder().setStatus("verified").build()); + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/gg/modl/backend/staff/data/Staff.java b/src/main/java/gg/modl/backend/staff/data/Staff.java index 2cf16f4..f1cbb74 100644 --- a/src/main/java/gg/modl/backend/staff/data/Staff.java +++ b/src/main/java/gg/modl/backend/staff/data/Staff.java @@ -1,154 +1,154 @@ -package gg.modl.backend.staff.data; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; -import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; -import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.annotation.Id; - -import org.springframework.data.mongodb.core.mapping.Document; -import org.springframework.data.mongodb.core.mapping.Field; - -@Document -@Data -@NoArgsConstructor -@AllArgsConstructor -@Builder -@JsonIgnoreProperties(ignoreUnknown = true) -@GenerateMongoFields -@MongoFieldAliases({ - @MongoFieldAlias(name = "SUBSCRIBED_TICKET_TICKET_ID", path = "subscribedTickets.ticketId"), - @MongoFieldAlias(name = "SUBSCRIBED_TICKET_ACTIVE", path = "subscribedTickets.active"), - @MongoFieldAlias(name = "SUBSCRIBED_TICKET_POS_ACTIVE", path = "subscribedTickets.$.active"), - @MongoFieldAlias(name = "SUBSCRIBED_TICKET_POS_LAST_READ_AT", path = "subscribedTickets.$.lastReadAt") -}) -public class Staff { - @Id - private String id; - - @Field("email") - private String email; - - @Field("username") - private String username; - - @Field("role") - private String roleId; - - - @Field("assignedMinecraftUuid") - private String assignedMinecraftUuid; - - @Field("assignedMinecraftUsername") - private String assignedMinecraftUsername; - - @Field("language") - @Builder.Default - private String language = "en"; - - @Field("dateFormat") - @Builder.Default - private String dateFormat = "MM/DD/YYYY"; - - @Field("subscribedTickets") - @Builder.Default - private List subscribedTickets = new ArrayList<>(); - - @Field("ticketSubscriptionSettings") - private TicketSubscriptionSettings ticketSubscriptionSettings; - - @Field("twoFactorToken") - private String twoFactorToken; - - @Field("twoFactorTokenIp") - private String twoFactorTokenIp; - - @Field("twoFactorTokenCreatedAt") - private Long twoFactorTokenCreatedAt; - - @Field("twoFactorPendingDelivery") - @Builder.Default - private boolean twoFactorPendingDelivery = false; - - @Field("twoFactorSessionIp") - private String twoFactorSessionIp; - - @Field("twoFactorSessionExpiresAt") - private Long twoFactorSessionExpiresAt; - - @Field("lastSeen") - private Date lastSeen; - - @Field("createdAt") - private Date createdAt; - - @Field("updatedAt") - private Date updatedAt; - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - @JsonIgnoreProperties(ignoreUnknown = true) - public static class TicketSubscription { - @Field("ticketId") - private String ticketId; - @Field("subscribedAt") - private Date subscribedAt; - @Field("lastReadAt") - private Date lastReadAt; - @Field("active") - @Builder.Default - private boolean active = true; - } - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - @JsonIgnoreProperties(ignoreUnknown = true) - public static class TicketSubscriptionSettings { - @Field("enabled") - @Builder.Default - private boolean enabled = true; - @Field("frequency") - private String frequency; - @Field("emailNotifications") - private NotificationSettings emailNotifications; - @Field("pushNotifications") - private NotificationSettings pushNotifications; - } - - @Data - @NoArgsConstructor - @AllArgsConstructor - @Builder - @JsonIgnoreProperties(ignoreUnknown = true) - public static class NotificationSettings { - @Field("enabled") - @Builder.Default - private boolean enabled = false; - @Field("newTickets") - @Builder.Default - private boolean newTickets = false; - @Field("ticketReplies") - @Builder.Default - private boolean ticketReplies = false; - @Field("ticketStatusChanges") - @Builder.Default - private boolean ticketStatusChanges = false; - @Field("ticketAssignments") - @Builder.Default - private boolean ticketAssignments = false; - @Field("subscribedTypes") - @Builder.Default - private List subscribedTypes = new ArrayList<>(); - } -} +package gg.modl.backend.staff.data; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; +import gg.modl.backend.database.mongo.codegen.MongoFieldAlias; +import gg.modl.backend.database.mongo.codegen.MongoFieldAliases; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Document +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +@GenerateMongoFields +@MongoFieldAliases({ + @MongoFieldAlias(name = "SUBSCRIBED_TICKET_TICKET_ID", path = "subscribedTickets.ticketId"), + @MongoFieldAlias(name = "SUBSCRIBED_TICKET_ACTIVE", path = "subscribedTickets.active"), + @MongoFieldAlias(name = "SUBSCRIBED_TICKET_POS_ACTIVE", path = "subscribedTickets.$.active"), + @MongoFieldAlias(name = "SUBSCRIBED_TICKET_POS_LAST_READ_AT", path = "subscribedTickets.$.lastReadAt") +}) +public class Staff { + @Id + private String id; + + @Field("email") + private String email; + + @Field("username") + private String username; + + @Field("role") + private String roleId; + + + @Field("assignedMinecraftUuid") + private String assignedMinecraftUuid; + + @Field("assignedMinecraftUsername") + private String assignedMinecraftUsername; + + @Field("language") + @Builder.Default + private String language = "en"; + + @Field("dateFormat") + @Builder.Default + private String dateFormat = "MM/DD/YYYY"; + + @Field("subscribedTickets") + @Builder.Default + private List subscribedTickets = new ArrayList<>(); + + @Field("ticketSubscriptionSettings") + private TicketSubscriptionSettings ticketSubscriptionSettings; + + @Field("twoFactorToken") + private String twoFactorToken; + + @Field("twoFactorTokenIp") + private String twoFactorTokenIp; + + @Field("twoFactorTokenCreatedAt") + private Long twoFactorTokenCreatedAt; + + @Field("twoFactorPendingDelivery") + @Builder.Default + private boolean twoFactorPendingDelivery = false; + + @Field("twoFactorSessionIp") + private String twoFactorSessionIp; + + @Field("twoFactorSessionExpiresAt") + private Long twoFactorSessionExpiresAt; + + @Field("lastSeen") + private Date lastSeen; + + @Field("createdAt") + private Date createdAt; + + @Field("updatedAt") + private Date updatedAt; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TicketSubscription { + @Field("ticketId") + private String ticketId; + @Field("subscribedAt") + private Date subscribedAt; + @Field("lastReadAt") + private Date lastReadAt; + @Field("active") + @Builder.Default + private boolean active = true; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public static class TicketSubscriptionSettings { + @Field("enabled") + @Builder.Default + private boolean enabled = true; + @Field("frequency") + private String frequency; + @Field("emailNotifications") + private NotificationSettings emailNotifications; + @Field("pushNotifications") + private NotificationSettings pushNotifications; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @JsonIgnoreProperties(ignoreUnknown = true) + public static class NotificationSettings { + @Field("enabled") + @Builder.Default + private boolean enabled = false; + @Field("newTickets") + @Builder.Default + private boolean newTickets = false; + @Field("ticketReplies") + @Builder.Default + private boolean ticketReplies = false; + @Field("ticketStatusChanges") + @Builder.Default + private boolean ticketStatusChanges = false; + @Field("ticketAssignments") + @Builder.Default + private boolean ticketAssignments = false; + @Field("subscribedTypes") + @Builder.Default + private List subscribedTypes = new ArrayList<>(); + } +} diff --git a/src/main/java/gg/modl/backend/staff/dto/response/AvailablePlayerResponse.java b/src/main/java/gg/modl/backend/staff/dto/response/AvailablePlayerResponse.java index bb21fcb..cad9144 100644 --- a/src/main/java/gg/modl/backend/staff/dto/response/AvailablePlayerResponse.java +++ b/src/main/java/gg/modl/backend/staff/dto/response/AvailablePlayerResponse.java @@ -1,7 +1,7 @@ -package gg.modl.backend.staff.dto.response; - -public record AvailablePlayerResponse( - String uuid, - String username -) { -} +package gg.modl.backend.staff.dto.response; + +public record AvailablePlayerResponse( + String uuid, + String username +) { +} diff --git a/src/main/java/gg/modl/backend/staff/dto/response/InviteResultResponse.java b/src/main/java/gg/modl/backend/staff/dto/response/InviteResultResponse.java index 5d1b9f2..de2d1c6 100644 --- a/src/main/java/gg/modl/backend/staff/dto/response/InviteResultResponse.java +++ b/src/main/java/gg/modl/backend/staff/dto/response/InviteResultResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.staff.dto.response; - -import java.util.List; - -public record InviteResultResponse( - String message, - List success, - List failed -) { - public record FailedInvite( - String email, - String reason - ) { - } -} +package gg.modl.backend.staff.dto.response; + +import java.util.List; + +public record InviteResultResponse( + String message, + List success, + List failed +) { + public record FailedInvite( + String email, + String reason + ) { + } +} diff --git a/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffPermissionsResponse.java b/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffPermissionsResponse.java index 52d7d09..c4cfdcc 100644 --- a/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffPermissionsResponse.java +++ b/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffPermissionsResponse.java @@ -1,14 +1,14 @@ -package gg.modl.backend.staff.dto.response; - -import java.util.List; - -public record MinecraftStaffPermissionsResponse( - String minecraftUuid, - String minecraftUsername, - String staffUsername, - String staffId, - String staffRole, - List permissions, - String email -) { -} +package gg.modl.backend.staff.dto.response; + +import java.util.List; + +public record MinecraftStaffPermissionsResponse( + String minecraftUuid, + String minecraftUsername, + String staffUsername, + String staffId, + String staffRole, + List permissions, + String email +) { +} diff --git a/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffSummaryResponse.java b/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffSummaryResponse.java index 35e7928..421742a 100644 --- a/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffSummaryResponse.java +++ b/src/main/java/gg/modl/backend/staff/dto/response/MinecraftStaffSummaryResponse.java @@ -1,21 +1,21 @@ -package gg.modl.backend.staff.dto.response; - -import java.util.Date; -import java.util.List; - -public record MinecraftStaffSummaryResponse( - String id, - String username, - String email, - String role, - String minecraftUuid, - String minecraftUsername, - List permissions, - Date lastSeen, - long totalPlaytimeMs, - String lastServer, - int punishmentsIssuedCount, - Date createdAt, - Date updatedAt -) { -} +package gg.modl.backend.staff.dto.response; + +import java.util.Date; +import java.util.List; + +public record MinecraftStaffSummaryResponse( + String id, + String username, + String email, + String role, + String minecraftUuid, + String minecraftUsername, + List permissions, + Date lastSeen, + long totalPlaytimeMs, + String lastServer, + int punishmentsIssuedCount, + Date createdAt, + Date updatedAt +) { +} diff --git a/src/main/java/gg/modl/backend/staff/dto/response/StaffResponse.java b/src/main/java/gg/modl/backend/staff/dto/response/StaffResponse.java index ef3bc04..e782b9d 100644 --- a/src/main/java/gg/modl/backend/staff/dto/response/StaffResponse.java +++ b/src/main/java/gg/modl/backend/staff/dto/response/StaffResponse.java @@ -1,15 +1,15 @@ -package gg.modl.backend.staff.dto.response; - -import java.util.Date; - -public record StaffResponse( - String id, - String email, - String username, - String role, - String status, - String assignedMinecraftUuid, - String assignedMinecraftUsername, - Date createdAt -) { -} +package gg.modl.backend.staff.dto.response; + +import java.util.Date; + +public record StaffResponse( + String id, + String email, + String username, + String role, + String status, + String assignedMinecraftUuid, + String assignedMinecraftUsername, + Date createdAt +) { +} diff --git a/src/main/java/gg/modl/backend/staff/service/InvitationService.java b/src/main/java/gg/modl/backend/staff/service/InvitationService.java index e0a478b..d2786e4 100644 --- a/src/main/java/gg/modl/backend/staff/service/InvitationService.java +++ b/src/main/java/gg/modl/backend/staff/service/InvitationService.java @@ -1,297 +1,297 @@ -package gg.modl.backend.staff.service; - -import gg.modl.backend.infrastructure.config.ModlProperties; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.infrastructure.exception.ConflictException; -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.database.mongo.repository.InvitationMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.email.EmailService; -import gg.modl.backend.limits.ServerLimitPolicy; -import gg.modl.backend.role.data.StaffRole; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.role.service.RoleAuthorization; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.settings.service.GeneralSettingsService; -import gg.modl.backend.staff.data.Invitation; -import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.dto.request.InviteStaffRequest; -import gg.modl.backend.staff.dto.response.InviteResultResponse; -import gg.modl.backend.staff.dto.response.StaffResponse; -import gg.modl.backend.infrastructure.util.IdGenerator; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class InvitationService { - private final StaffMongoRepository staffRepository; - private final InvitationMongoRepository invitationRepository; - private final EmailService emailService; - private final IdGenerator idGenerator; - private final ModlProperties modlProperties; - private final PermissionService permissionService; - private final RoleAuthorization roleAuthorization; - private final ServerLimitPolicy serverLimitPolicy; - private final GeneralSettingsService generalSettingsService; - - private static final long INVITATION_EXPIRY_MS = 24 * 60 * 60 * 1000; - - public InviteResultResponse sendInvitations(Server server, InviteStaffRequest request, RoleAuthorization.PerformerAuthority performer) { - List emailsToInvite = new ArrayList<>(); - if (request.emails() != null && !request.emails().isEmpty()) { - emailsToInvite.addAll(request.emails()); - } else if (request.email() != null) { - emailsToInvite.add(request.email()); - } - - if (emailsToInvite.isEmpty()) { - throw new ValidationException("No emails provided"); - } - - List normalizedEmailsToInvite = emailsToInvite.stream() - .filter(email -> email != null && !email.isBlank()) - .map(EmailAddressUtil::normalize) - .distinct() - .toList(); - - if (normalizedEmailsToInvite.isEmpty()) { - throw new ValidationException("No valid emails provided"); - } - StaffRole grantedRole = roleAuthorization.assertGrantableRole(server, performer, request.role()); - - long staffLimit = staffLimitFor(server); - long currentStaffCount = staffRepository.countAll(server); - long pendingInvitationsCount = invitationRepository.countActive(server, new Date()); - long totalCurrentMembers = currentStaffCount + pendingInvitationsCount; - - if (totalCurrentMembers >= staffLimit) { - String planName = server.getPlan() == ServerPlan.PREMIUM ? "Premium" : "Free"; - throw new ConflictException( - String.format("Staff member limit reached. Your %s plan allows up to %d staff members. " + - "Please upgrade your plan or remove existing staff members to invite new ones.", - planName, staffLimit) - ); - } - - int availableSlots = (int) (staffLimit - totalCurrentMembers); - if (normalizedEmailsToInvite.size() > availableSlots) { - throw new ConflictException( - String.format("Cannot invite %d staff members. You only have %d available slot(s) remaining.", - normalizedEmailsToInvite.size(), availableSlots) - ); - } - - List success = new ArrayList<>(); - List failed = new ArrayList<>(); - - for (String email : normalizedEmailsToInvite) { - try { - processInvitation(server, email, grantedRole, failed); - if (failed.stream().noneMatch(f -> f.email().equals(email))) { - success.add(email); - } - } catch (Exception e) { - log.error("Error processing invitation for {}", email, e); - failed.add(new InviteResultResponse.FailedInvite(email, "Internal server error")); - } - } - - String message; - if (success.isEmpty()) { - message = "No invitations were sent successfully."; - } else if (failed.isEmpty()) { - message = success.size() == 1 ? "Invitation sent successfully." : - success.size() + " invitations sent successfully."; - } else { - message = success.size() + " invitation(s) sent successfully, " + failed.size() + " failed."; - } - - return new InviteResultResponse(message, success, failed); - } - - private long staffLimitFor(Server server) { - return serverLimitPolicy.resolve(server).getMaxStaffSeats(); - } - - private int availableSeats(Server server) { - long staffLimit = staffLimitFor(server); - long current = staffRepository.countAll(server) + invitationRepository.countActive(server, new Date()); - return (int) (staffLimit - current); - } - - private void processInvitation(Server server, String email, StaffRole role, - List failed) { - String normalizedEmail = EmailAddressUtil.normalize(email); - - if (server.getAdminEmail() != null && normalizedEmail.equalsIgnoreCase(server.getAdminEmail())) { - failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "Cannot send invitation to the admin email address.")); - return; - } - - if (staffRepository.existsByEmailExact(server, normalizedEmail)) { - failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "Email is already associated with an existing user.")); - return; - } - - if (invitationRepository.existsByEmailActive(server, normalizedEmail, new Date())) { - failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "An invitation for this email is already pending.")); - return; - } - - if (availableSeats(server) <= 0) { - failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, - "Staff member limit reached. Please remove a staff member or upgrade your plan.")); - return; - } - - String token = idGenerator.generateToken(); - Date expiresAt = new Date(System.currentTimeMillis() + INVITATION_EXPIRY_MS); - - Invitation invitation = Invitation.builder() - .email(normalizedEmail) - .roleId(role.getId()) - .token(token) - .expiresAt(expiresAt) - .createdAt(new Date()) - .updatedAt(new Date()) - .build(); - - invitationRepository.saveEntity(server, invitation); - - String invitationLink = String.format("https://%s.%s/accept-invitation?token=%s", - server.getCustomDomain(), modlProperties.getDomain(), token); - - try { - emailService.sendStaffInviteEmail( - normalizedEmail, - server.getServerName(), - role.getName(), - invitationLink - ); - } catch (Exception e) { - log.error("Failed to send invitation email to {}", normalizedEmail, e); - invitationRepository.deleteById(server, invitation.getId()); - failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "Failed to send invitation email.")); - } - } - - public boolean resendInvitation(Server server, String invitationId) { - Invitation invitation = invitationRepository.findById(server, invitationId).orElse(null); - - if (invitation == null) { - return false; - } - - String previousToken = invitation.getToken(); - Date previousExpiry = invitation.getExpiresAt(); - - String newToken = idGenerator.generateToken(); - Date newExpiry = new Date(System.currentTimeMillis() + INVITATION_EXPIRY_MS); - - invitationRepository.refreshToken(server, invitationId, newToken, newExpiry, new Date()); - - String invitationLink = String.format("https://%s.%s/accept-invitation?token=%s", - server.getCustomDomain(), modlProperties.getDomain(), newToken); - - try { - emailService.sendStaffInviteEmail( - invitation.getEmail(), - server.getServerName(), - permissionService.resolveRoleName(server, invitation.getRoleId()), - invitationLink - ); - } catch (Exception e) { - log.error("Failed to resend invitation email to {}, restoring previous token", invitation.getEmail(), e); - invitationRepository.refreshToken(server, invitationId, previousToken, previousExpiry, new Date()); - throw e; - } - - return true; - } - - public StaffResponse acceptInvitation(Server server, String token) { - Invitation invitation = invitationRepository.findByToken(server, token).orElse(null); - - if (invitation == null) { - throw new ValidationException("Invalid or expired invitation token."); - } - - if (invitation.getExpiresAt() == null || invitation.getExpiresAt().before(new Date())) { - throw new ValidationException("This invitation has expired. Please request a new invitation."); - } - - if (staffRepository.existsByEmailExact(server, invitation.getEmail())) { - throw new ConflictException("A staff member with this email already exists."); - } - StaffRole invitationRole = resolveInvitationRole(server, invitation.getRoleId()); - - long staffLimit = staffLimitFor(server); - long occupied = staffRepository.countAll(server) + invitationRepository.countActive(server, new Date()) - 1; - if (occupied >= staffLimit) { - throw new ConflictException("Staff member limit reached for this server. Please contact an administrator."); - } - - String username = generateUsernameFromEmail(invitation.getEmail()); - String uniqueUsername = ensureUniqueUsername(server, username); - - Date now = new Date(); - Staff newStaff = Staff.builder() - .email(invitation.getEmail()) - .username(uniqueUsername) - .roleId(invitation.getRoleId()) - .language(generalSettingsService.getGeneralSettings(server).getDefaultLanguage()) - .createdAt(now) - .updatedAt(now) - .build(); - - staffRepository.saveEntity(server, newStaff); - - invitationRepository.deleteById(server, invitation.getId()); - - return new StaffResponse( - newStaff.getId(), - newStaff.getEmail(), - newStaff.getUsername(), - invitationRole.getName(), - "active", - newStaff.getAssignedMinecraftUuid(), - newStaff.getAssignedMinecraftUsername(), - newStaff.getCreatedAt() - ); - } - - private String generateUsernameFromEmail(String email) { - String localPart = email.split("@")[0]; - return localPart.replaceAll("[^a-zA-Z0-9_]", "_"); - } - - private String ensureUniqueUsername(Server server, String baseUsername) { - String username = baseUsername; - int counter = 1; - - while (staffRepository.existsByUsername(server, username)) { - username = baseUsername + counter; - counter++; - } - - return username; - } - - private StaffRole resolveInvitationRole(Server server, String roleId) { - StaffRole role = permissionService.getRoleById(server, roleId) - .orElseThrow(() -> new ValidationException( - "This invitation references a role that no longer exists. Please request a new invitation.")); - if (RoleAuthorization.isSuperAdminRole(role)) { - throw new ForbiddenException("This invitation role must be reissued by an administrator"); - } - return role; - } -} +package gg.modl.backend.staff.service; + +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.database.mongo.repository.InvitationMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.email.EmailService; +import gg.modl.backend.limits.ServerLimitPolicy; +import gg.modl.backend.role.data.StaffRole; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.role.service.RoleAuthorization; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.settings.service.GeneralSettingsService; +import gg.modl.backend.staff.data.Invitation; +import gg.modl.backend.staff.data.Staff; +import gg.modl.backend.staff.dto.request.InviteStaffRequest; +import gg.modl.backend.staff.dto.response.InviteResultResponse; +import gg.modl.backend.staff.dto.response.StaffResponse; +import gg.modl.backend.infrastructure.util.IdGenerator; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class InvitationService { + private final StaffMongoRepository staffRepository; + private final InvitationMongoRepository invitationRepository; + private final EmailService emailService; + private final IdGenerator idGenerator; + private final ModlProperties modlProperties; + private final PermissionService permissionService; + private final RoleAuthorization roleAuthorization; + private final ServerLimitPolicy serverLimitPolicy; + private final GeneralSettingsService generalSettingsService; + + private static final long INVITATION_EXPIRY_MS = 24 * 60 * 60 * 1000; + + public InviteResultResponse sendInvitations(Server server, InviteStaffRequest request, RoleAuthorization.PerformerAuthority performer) { + List emailsToInvite = new ArrayList<>(); + if (request.emails() != null && !request.emails().isEmpty()) { + emailsToInvite.addAll(request.emails()); + } else if (request.email() != null) { + emailsToInvite.add(request.email()); + } + + if (emailsToInvite.isEmpty()) { + throw new ValidationException("No emails provided"); + } + + List normalizedEmailsToInvite = emailsToInvite.stream() + .filter(email -> email != null && !email.isBlank()) + .map(EmailAddressUtil::normalize) + .distinct() + .toList(); + + if (normalizedEmailsToInvite.isEmpty()) { + throw new ValidationException("No valid emails provided"); + } + StaffRole grantedRole = roleAuthorization.assertGrantableRole(server, performer, request.role()); + + long staffLimit = staffLimitFor(server); + long currentStaffCount = staffRepository.countAll(server); + long pendingInvitationsCount = invitationRepository.countActive(server, new Date()); + long totalCurrentMembers = currentStaffCount + pendingInvitationsCount; + + if (totalCurrentMembers >= staffLimit) { + String planName = server.getPlan() == ServerPlan.PREMIUM ? "Premium" : "Free"; + throw new ConflictException( + String.format("Staff member limit reached. Your %s plan allows up to %d staff members. " + + "Please upgrade your plan or remove existing staff members to invite new ones.", + planName, staffLimit) + ); + } + + int availableSlots = (int) (staffLimit - totalCurrentMembers); + if (normalizedEmailsToInvite.size() > availableSlots) { + throw new ConflictException( + String.format("Cannot invite %d staff members. You only have %d available slot(s) remaining.", + normalizedEmailsToInvite.size(), availableSlots) + ); + } + + List success = new ArrayList<>(); + List failed = new ArrayList<>(); + + for (String email : normalizedEmailsToInvite) { + try { + processInvitation(server, email, grantedRole, failed); + if (failed.stream().noneMatch(f -> f.email().equals(email))) { + success.add(email); + } + } catch (Exception e) { + log.error("Error processing invitation for {}", email, e); + failed.add(new InviteResultResponse.FailedInvite(email, "Internal server error")); + } + } + + String message; + if (success.isEmpty()) { + message = "No invitations were sent successfully."; + } else if (failed.isEmpty()) { + message = success.size() == 1 ? "Invitation sent successfully." : + success.size() + " invitations sent successfully."; + } else { + message = success.size() + " invitation(s) sent successfully, " + failed.size() + " failed."; + } + + return new InviteResultResponse(message, success, failed); + } + + private long staffLimitFor(Server server) { + return serverLimitPolicy.resolve(server).getMaxStaffSeats(); + } + + private int availableSeats(Server server) { + long staffLimit = staffLimitFor(server); + long current = staffRepository.countAll(server) + invitationRepository.countActive(server, new Date()); + return (int) (staffLimit - current); + } + + private void processInvitation(Server server, String email, StaffRole role, + List failed) { + String normalizedEmail = EmailAddressUtil.normalize(email); + + if (server.getAdminEmail() != null && normalizedEmail.equalsIgnoreCase(server.getAdminEmail())) { + failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "Cannot send invitation to the admin email address.")); + return; + } + + if (staffRepository.existsByEmailExact(server, normalizedEmail)) { + failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "Email is already associated with an existing user.")); + return; + } + + if (invitationRepository.existsByEmailActive(server, normalizedEmail, new Date())) { + failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "An invitation for this email is already pending.")); + return; + } + + if (availableSeats(server) <= 0) { + failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, + "Staff member limit reached. Please remove a staff member or upgrade your plan.")); + return; + } + + String token = idGenerator.generateToken(); + Date expiresAt = new Date(System.currentTimeMillis() + INVITATION_EXPIRY_MS); + + Invitation invitation = Invitation.builder() + .email(normalizedEmail) + .roleId(role.getId()) + .token(token) + .expiresAt(expiresAt) + .createdAt(new Date()) + .updatedAt(new Date()) + .build(); + + invitationRepository.saveEntity(server, invitation); + + String invitationLink = String.format("https://%s.%s/accept-invitation?token=%s", + server.getCustomDomain(), modlProperties.getDomain(), token); + + try { + emailService.sendStaffInviteEmail( + normalizedEmail, + server.getServerName(), + role.getName(), + invitationLink + ); + } catch (Exception e) { + log.error("Failed to send invitation email to {}", normalizedEmail, e); + invitationRepository.deleteById(server, invitation.getId()); + failed.add(new InviteResultResponse.FailedInvite(normalizedEmail, "Failed to send invitation email.")); + } + } + + public boolean resendInvitation(Server server, String invitationId) { + Invitation invitation = invitationRepository.findById(server, invitationId).orElse(null); + + if (invitation == null) { + return false; + } + + String previousToken = invitation.getToken(); + Date previousExpiry = invitation.getExpiresAt(); + + String newToken = idGenerator.generateToken(); + Date newExpiry = new Date(System.currentTimeMillis() + INVITATION_EXPIRY_MS); + + invitationRepository.refreshToken(server, invitationId, newToken, newExpiry, new Date()); + + String invitationLink = String.format("https://%s.%s/accept-invitation?token=%s", + server.getCustomDomain(), modlProperties.getDomain(), newToken); + + try { + emailService.sendStaffInviteEmail( + invitation.getEmail(), + server.getServerName(), + permissionService.resolveRoleName(server, invitation.getRoleId()), + invitationLink + ); + } catch (Exception e) { + log.error("Failed to resend invitation email to {}, restoring previous token", invitation.getEmail(), e); + invitationRepository.refreshToken(server, invitationId, previousToken, previousExpiry, new Date()); + throw e; + } + + return true; + } + + public StaffResponse acceptInvitation(Server server, String token) { + Invitation invitation = invitationRepository.findByToken(server, token).orElse(null); + + if (invitation == null) { + throw new ValidationException("Invalid or expired invitation token."); + } + + if (invitation.getExpiresAt() == null || invitation.getExpiresAt().before(new Date())) { + throw new ValidationException("This invitation has expired. Please request a new invitation."); + } + + if (staffRepository.existsByEmailExact(server, invitation.getEmail())) { + throw new ConflictException("A staff member with this email already exists."); + } + StaffRole invitationRole = resolveInvitationRole(server, invitation.getRoleId()); + + long staffLimit = staffLimitFor(server); + long occupied = staffRepository.countAll(server) + invitationRepository.countActive(server, new Date()) - 1; + if (occupied >= staffLimit) { + throw new ConflictException("Staff member limit reached for this server. Please contact an administrator."); + } + + String username = generateUsernameFromEmail(invitation.getEmail()); + String uniqueUsername = ensureUniqueUsername(server, username); + + Date now = new Date(); + Staff newStaff = Staff.builder() + .email(invitation.getEmail()) + .username(uniqueUsername) + .roleId(invitation.getRoleId()) + .language(generalSettingsService.getGeneralSettings(server).getDefaultLanguage()) + .createdAt(now) + .updatedAt(now) + .build(); + + staffRepository.saveEntity(server, newStaff); + + invitationRepository.deleteById(server, invitation.getId()); + + return new StaffResponse( + newStaff.getId(), + newStaff.getEmail(), + newStaff.getUsername(), + invitationRole.getName(), + "active", + newStaff.getAssignedMinecraftUuid(), + newStaff.getAssignedMinecraftUsername(), + newStaff.getCreatedAt() + ); + } + + private String generateUsernameFromEmail(String email) { + String localPart = email.split("@")[0]; + return localPart.replaceAll("[^a-zA-Z0-9_]", "_"); + } + + private String ensureUniqueUsername(Server server, String baseUsername) { + String username = baseUsername; + int counter = 1; + + while (staffRepository.existsByUsername(server, username)) { + username = baseUsername + counter; + counter++; + } + + return username; + } + + private StaffRole resolveInvitationRole(Server server, String roleId) { + StaffRole role = permissionService.getRoleById(server, roleId) + .orElseThrow(() -> new ValidationException( + "This invitation references a role that no longer exists. Please request a new invitation.")); + if (RoleAuthorization.isSuperAdminRole(role)) { + throw new ForbiddenException("This invitation role must be reissued by an administrator"); + } + return role; + } +} diff --git a/src/main/java/gg/modl/backend/storage/controller/PanelStorageController.java b/src/main/java/gg/modl/backend/storage/controller/PanelStorageController.java index 9b40296..09b5a9f 100644 --- a/src/main/java/gg/modl/backend/storage/controller/PanelStorageController.java +++ b/src/main/java/gg/modl/backend/storage/controller/PanelStorageController.java @@ -1,111 +1,111 @@ -package gg.modl.backend.storage.controller; - -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.backend.replay.service.ReplayDeletionService; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.storage.dto.response.StorageFileResponse; -import gg.modl.backend.storage.service.MediaValidationService; -import gg.modl.backend.storage.service.S3StorageService; -import gg.modl.backend.storage.service.StorageMetadataService; -import gg.modl.backend.storage.service.StorageQuotaService; -import gg.modl.backend.storage.service.StorageSyncService; -import gg.modl.proto.modl.v1.BulkDeleteRequest; -import gg.modl.proto.modl.v1.StorageBulkDeleteResponse; -import gg.modl.proto.modl.v1.StorageDownloadUrlResponse; -import gg.modl.proto.modl.v1.StorageFilesResponse; -import gg.modl.proto.modl.v1.StorageQuotaResponse; -import gg.modl.proto.modl.v1.StorageSyncResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import lombok.RequiredArgsConstructor; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_STORAGE) -@RequiredArgsConstructor -public class PanelStorageController { - private final S3StorageService s3StorageService; - private final StorageQuotaService quotaService; - private final StorageMetadataService storageMetadataService; - private final StorageSyncService storageSyncService; - private final PermissionService permissionService; - private final MediaValidationService validationService; - private final ReplayDeletionService replayDeletionService; - - @GetMapping("/quota") - public ResponseEntity getQuota(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(StorageProtoMapper.toStorageQuotaResponse(quotaService.getQuota(server))); - } - - @GetMapping("/files") - public ResponseEntity getFiles( - @RequestParam(required = false) String prefix, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List files = storageMetadataService.listFiles(server, prefix); - return ResponseEntity.ok(StorageProtoMapper.toStorageFilesResponse(files)); - } - - @PostMapping("/bulk-delete") - public ResponseEntity bulkDelete( - @RequestBody BulkDeleteRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - List keys = body.getKeysList(); - - if (keys.size() > RequestValidationLimits.STORAGE_BULK_DELETE_MAX_KEYS) { - throw new ValidationException("Too many keys in bulk delete request. Maximum is " + RequestValidationLimits.STORAGE_BULK_DELETE_MAX_KEYS); - } - for (String key : keys) { - validationService.assertKeyOwnedByServer(server, key); - } - - int deleted = s3StorageService.bulkDelete(keys); - storageMetadataService.removeFiles(server, keys); - replayDeletionService.reconcileDeletedStorageKeys(server, keys); - return ResponseEntity.ok(StorageProtoMapper.toStorageBulkDeleteResponse(deleted)); - } - - @PostMapping("/sync") - public ResponseEntity syncFiles(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - if (!permissionService.isSuperAdmin(server, RequestUtil.getSessionEmail(request))) { - throw new ForbiddenException("Only super admins can trigger a storage sync"); - } - int synced = storageSyncService.syncServerFiles(server, true); - return ResponseEntity.ok(StorageProtoMapper.toStorageSyncResponse(synced)); - } - - @GetMapping("/download/{*key}") - public ResponseEntity getDownloadUrl( - @PathVariable String key, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - String normalizedKey = key.startsWith("/") ? key.substring(1) : key; - validationService.assertKeyOwnedByServer(server, normalizedKey); - - String url = s3StorageService.getPresignedUrl(normalizedKey); - if (url == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok(StorageProtoMapper.toStorageDownloadUrlResponse(url)); - } -} +package gg.modl.backend.storage.controller; + +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.replay.service.ReplayDeletionService; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.storage.dto.response.StorageFileResponse; +import gg.modl.backend.storage.service.MediaValidationService; +import gg.modl.backend.storage.service.S3StorageService; +import gg.modl.backend.storage.service.StorageMetadataService; +import gg.modl.backend.storage.service.StorageQuotaService; +import gg.modl.backend.storage.service.StorageSyncService; +import gg.modl.proto.modl.v1.BulkDeleteRequest; +import gg.modl.proto.modl.v1.StorageBulkDeleteResponse; +import gg.modl.proto.modl.v1.StorageDownloadUrlResponse; +import gg.modl.proto.modl.v1.StorageFilesResponse; +import gg.modl.proto.modl.v1.StorageQuotaResponse; +import gg.modl.proto.modl.v1.StorageSyncResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_STORAGE) +@RequiredArgsConstructor +public class PanelStorageController { + private final S3StorageService s3StorageService; + private final StorageQuotaService quotaService; + private final StorageMetadataService storageMetadataService; + private final StorageSyncService storageSyncService; + private final PermissionService permissionService; + private final MediaValidationService validationService; + private final ReplayDeletionService replayDeletionService; + + @GetMapping("/quota") + public ResponseEntity getQuota(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(StorageProtoMapper.toStorageQuotaResponse(quotaService.getQuota(server))); + } + + @GetMapping("/files") + public ResponseEntity getFiles( + @RequestParam(required = false) String prefix, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List files = storageMetadataService.listFiles(server, prefix); + return ResponseEntity.ok(StorageProtoMapper.toStorageFilesResponse(files)); + } + + @PostMapping("/bulk-delete") + public ResponseEntity bulkDelete( + @RequestBody BulkDeleteRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + List keys = body.getKeysList(); + + if (keys.size() > RequestValidationLimits.STORAGE_BULK_DELETE_MAX_KEYS) { + throw new ValidationException("Too many keys in bulk delete request. Maximum is " + RequestValidationLimits.STORAGE_BULK_DELETE_MAX_KEYS); + } + for (String key : keys) { + validationService.assertKeyOwnedByServer(server, key); + } + + int deleted = s3StorageService.bulkDelete(keys); + storageMetadataService.removeFiles(server, keys); + replayDeletionService.reconcileDeletedStorageKeys(server, keys); + return ResponseEntity.ok(StorageProtoMapper.toStorageBulkDeleteResponse(deleted)); + } + + @PostMapping("/sync") + public ResponseEntity syncFiles(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + if (!permissionService.isSuperAdmin(server, RequestUtil.getSessionEmail(request))) { + throw new ForbiddenException("Only super admins can trigger a storage sync"); + } + int synced = storageSyncService.syncServerFiles(server, true); + return ResponseEntity.ok(StorageProtoMapper.toStorageSyncResponse(synced)); + } + + @GetMapping("/download/{*key}") + public ResponseEntity getDownloadUrl( + @PathVariable String key, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + String normalizedKey = key.startsWith("/") ? key.substring(1) : key; + validationService.assertKeyOwnedByServer(server, normalizedKey); + + String url = s3StorageService.getPresignedUrl(normalizedKey); + if (url == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(StorageProtoMapper.toStorageDownloadUrlResponse(url)); + } +} diff --git a/src/main/java/gg/modl/backend/storage/dto/response/PresignUploadResponse.java b/src/main/java/gg/modl/backend/storage/dto/response/PresignUploadResponse.java index dee063b..657a744 100644 --- a/src/main/java/gg/modl/backend/storage/dto/response/PresignUploadResponse.java +++ b/src/main/java/gg/modl/backend/storage/dto/response/PresignUploadResponse.java @@ -1,12 +1,12 @@ -package gg.modl.backend.storage.dto.response; - -import java.time.Instant; -import java.util.Map; - -public record PresignUploadResponse( - String presignedUrl, - String key, - Instant expiresAt, - String method, - Map requiredHeaders -) {} +package gg.modl.backend.storage.dto.response; + +import java.time.Instant; +import java.util.Map; + +public record PresignUploadResponse( + String presignedUrl, + String key, + Instant expiresAt, + String method, + Map requiredHeaders +) {} diff --git a/src/main/java/gg/modl/backend/storage/dto/response/StorageFileResponse.java b/src/main/java/gg/modl/backend/storage/dto/response/StorageFileResponse.java index dbb8a20..6242269 100644 --- a/src/main/java/gg/modl/backend/storage/dto/response/StorageFileResponse.java +++ b/src/main/java/gg/modl/backend/storage/dto/response/StorageFileResponse.java @@ -1,13 +1,13 @@ -package gg.modl.backend.storage.dto.response; - -import java.util.Date; - -public record StorageFileResponse( - String key, - String name, - long size, - String contentType, - Date lastModified, - String url -) { -} +package gg.modl.backend.storage.dto.response; + +import java.util.Date; + +public record StorageFileResponse( + String key, + String name, + long size, + String contentType, + Date lastModified, + String url +) { +} diff --git a/src/main/java/gg/modl/backend/storage/dto/response/StorageQuotaResponse.java b/src/main/java/gg/modl/backend/storage/dto/response/StorageQuotaResponse.java index 1a25941..a960018 100644 --- a/src/main/java/gg/modl/backend/storage/dto/response/StorageQuotaResponse.java +++ b/src/main/java/gg/modl/backend/storage/dto/response/StorageQuotaResponse.java @@ -1,25 +1,25 @@ -package gg.modl.backend.storage.dto.response; - -import java.util.Map; - -public record StorageQuotaResponse( - long usedBytes, - long maxBytes, - double usedPercentage, - String usedFormatted, - String maxFormatted, - Map byType, - AiQuotaInfo aiQuota, - boolean isPremium, - double storageOverageRate -) { - public record AiQuotaInfo( - long totalUsed, - long baseLimit, - long overageUsed, - double overageCost, - boolean canUseAI, - double usagePercentage, - Map byService - ) {} -} +package gg.modl.backend.storage.dto.response; + +import java.util.Map; + +public record StorageQuotaResponse( + long usedBytes, + long maxBytes, + double usedPercentage, + String usedFormatted, + String maxFormatted, + Map byType, + AiQuotaInfo aiQuota, + boolean isPremium, + double storageOverageRate +) { + public record AiQuotaInfo( + long totalUsed, + long baseLimit, + long overageUsed, + double overageCost, + boolean canUseAI, + double usagePercentage, + Map byService + ) {} +} diff --git a/src/main/java/gg/modl/backend/storage/dto/response/UploadResponse.java b/src/main/java/gg/modl/backend/storage/dto/response/UploadResponse.java index 6c2216d..1a0b40e 100644 --- a/src/main/java/gg/modl/backend/storage/dto/response/UploadResponse.java +++ b/src/main/java/gg/modl/backend/storage/dto/response/UploadResponse.java @@ -1,10 +1,10 @@ -package gg.modl.backend.storage.dto.response; - -public record UploadResponse( - String key, - String url, - String fileName, - long size, - String contentType -) { -} +package gg.modl.backend.storage.dto.response; + +public record UploadResponse( + String key, + String url, + String fileName, + long size, + String contentType +) { +} diff --git a/src/main/java/gg/modl/backend/storage/service/EvidenceUploadService.java b/src/main/java/gg/modl/backend/storage/service/EvidenceUploadService.java index f0554ba..f78c655 100644 --- a/src/main/java/gg/modl/backend/storage/service/EvidenceUploadService.java +++ b/src/main/java/gg/modl/backend/storage/service/EvidenceUploadService.java @@ -1,295 +1,295 @@ -package gg.modl.backend.storage.service; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.service.PlayerDataUtils; -import gg.modl.backend.player.service.PunishmentEvidenceService; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.player.service.PunishmentQueryService.UploadedEvidenceItem; -import gg.modl.backend.server.ServerService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.storage.dto.request.EvidenceConfirmUploadRequest; -import gg.modl.backend.storage.dto.request.EvidenceItemRequest; -import gg.modl.backend.storage.dto.request.EvidencePresignUploadRequest; -import gg.modl.backend.storage.dto.request.SubmitEvidenceRequest; -import gg.modl.backend.storage.data.StorageFileDocument; -import gg.modl.backend.storage.dto.response.PresignUploadResponse; -import gg.modl.backend.storage.dto.response.UploadResponse; -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class EvidenceUploadService { - private final EvidenceUploadTokenService tokenService; - private final S3StorageService s3StorageService; - private final PlayerMongoRepository playerRepository; - private final ServerService serverService; - private final MediaValidationService validationService; - private final PunishmentEvidenceService punishmentEvidenceService; - private final StorageMetadataService storageMetadataService; - private final UploadOrchestrationService uploadOrchestrationService; - - public TokenValidationResult validateToken(String token) { - EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); - if (uploadToken == null) { - return TokenValidationResult.invalid(); - } - - Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); - Player player = server == null - ? null - : playerRepository.findByMinecraftUuid(server, normalizeUuid(uploadToken.playerUuid())).orElse(null); - String playerName = player != null ? PlayerDataUtils.extractLatestUsername(player.getUsernames()) : "Unknown"; - - return TokenValidationResult.valid(new TokenInfo( - uploadToken.punishmentId(), - playerName, - uploadToken.issuerName() - )); - } - - public PresignUploadResult presignUpload(String token, EvidencePresignUploadRequest request) { - EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); - if (uploadToken == null) { - return PresignUploadResult.of(PresignUploadStatus.INVALID_TOKEN, null, null); - } - - if (!s3StorageService.isConfigured()) { - return PresignUploadResult.of(PresignUploadStatus.STORAGE_NOT_CONFIGURED, null, null); - } - - Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); - if (server == null) { - return PresignUploadResult.of(PresignUploadStatus.SERVER_NOT_FOUND, null, null); - } - - UploadOrchestrationService.PresignOutcome outcome = uploadOrchestrationService.presign(server, - new UploadOrchestrationService.UploadPresignRequest( - "evidence", - request.fileName(), - request.contentType(), - request.fileSize(), - uploadToken.punishmentId(), - server.getPlan() == ServerPlan.PREMIUM, - false - )); - - return switch (outcome.status()) { - case SUCCESS -> PresignUploadResult.of(PresignUploadStatus.SUCCESS, null, outcome.upload()); - case QUOTA_EXCEEDED -> PresignUploadResult.of(PresignUploadStatus.QUOTA_EXCEEDED, - "Storage quota exceeded. Please contact the server administrator.", null); - case VALIDATION_FAILED, TEMP_LIMIT_EXCEEDED -> - PresignUploadResult.of(PresignUploadStatus.VALIDATION_FAILED, outcome.message(), null); - }; - } - - public ConfirmUploadResult confirmUpload(String token, EvidenceConfirmUploadRequest request) { - EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); - if (uploadToken == null) { - return ConfirmUploadResult.of(ConfirmUploadStatus.INVALID_TOKEN, null); - } - - if (!validationService.isKeyOwnedByServer(request.key(), uploadToken.serverDatabaseName())) { - return ConfirmUploadResult.of(ConfirmUploadStatus.INVALID_KEY, null); - } - - String expectedKeyPrefix = uploadToken.serverDatabaseName() + "/evidence/" + uploadToken.punishmentId() + "/"; - if (!request.key().startsWith(expectedKeyPrefix)) { - return ConfirmUploadResult.of(ConfirmUploadStatus.INVALID_KEY, null); - } - - Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); - if (server == null) { - UploadResponse uploadDetails = s3StorageService.getUploadDetails(request.key()); - if (uploadDetails == null) { - return ConfirmUploadResult.of(ConfirmUploadStatus.UPLOAD_NOT_FOUND, null); - } - log.warn("Could not record storage metadata: server not found for database {}", uploadToken.serverDatabaseName()); - return ConfirmUploadResult.of(ConfirmUploadStatus.SUCCESS, uploadDetails); - } - - UploadOrchestrationService.ConfirmOutcome outcome = - uploadOrchestrationService.confirm(server, request.key(), false); - return switch (outcome.status()) { - case SUCCESS -> ConfirmUploadResult.of(ConfirmUploadStatus.SUCCESS, outcome.upload()); - case UPLOAD_NOT_FOUND -> ConfirmUploadResult.of(ConfirmUploadStatus.UPLOAD_NOT_FOUND, null); - case QUOTA_EXCEEDED -> ConfirmUploadResult.of(ConfirmUploadStatus.QUOTA_EXCEEDED, null); - case RECORD_FAILED, TEMP_LIMIT_EXCEEDED -> ConfirmUploadResult.of(ConfirmUploadStatus.RECORD_FAILED, null); - }; - } - - public SubmitEvidenceResult submitEvidence(String token, SubmitEvidenceRequest request) { - EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); - if (uploadToken == null) { - return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_TOKEN, null); - } - - Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); - if (server == null) { - return SubmitEvidenceResult.of(SubmitEvidenceStatus.SERVER_NOT_FOUND, null); - } - - List items = request.evidence(); - List keys = new ArrayList<>(items.size()); - for (EvidenceItemRequest item : items) { - if (!isAllowedEvidenceUrl(item.url(), uploadToken)) { - return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_URL, null); - } - - String key = extractKeyFromEvidenceUrl(item.url()); - if (key == null) { - return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_URL, null); - } - keys.add(key); - } - - Map confirmedFiles = storageMetadataService.findConfirmedFiles(server, keys); - - List evidenceItems = new ArrayList<>(items.size()); - for (int i = 0; i < items.size(); i++) { - EvidenceItemRequest item = items.get(i); - StorageFileDocument doc = confirmedFiles.get(keys.get(i)); - if (doc == null) { - return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_URL, null); - } - - evidenceItems.add(new UploadedEvidenceItem( - item.url(), - item.fileName(), - doc.getContentType(), - doc.getSize() - )); - } - - PunishmentOperationResult result = punishmentEvidenceService.addUploadedEvidence( - server, - uploadToken.punishmentId(), - uploadToken.issuerName(), - null, - evidenceItems - ); - if (result.status() == PunishmentOperationStatus.NOT_FOUND) { - return SubmitEvidenceResult.of(SubmitEvidenceStatus.PUNISHMENT_NOT_FOUND, result.message()); - } - - tokenService.invalidateToken(token); - return SubmitEvidenceResult.of(SubmitEvidenceStatus.SUCCESS, null); - } - - private String extractKeyFromEvidenceUrl(String url) { - try { - String path = URI.create(url).getPath(); - if (path == null || path.isBlank()) { - return null; - } - return path.startsWith("/") ? path.substring(1) : path; - } catch (IllegalArgumentException exception) { - return null; - } - } - - private boolean isAllowedEvidenceUrl(String url, EvidenceUploadTokenService.UploadToken uploadToken) { - if (url == null || url.isBlank()) { - return false; - } - - try { - URI uri = URI.create(url); - String scheme = uri.getScheme(); - if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) { - return false; - } - - String expectedPathFragment = "/" + uploadToken.serverDatabaseName() + "/evidence/" + uploadToken.punishmentId() + "/"; - String path = uri.getPath(); - if (path == null || !path.contains(expectedPathFragment)) { - return false; - } - - String cdnDomain = s3StorageService.getCdnDomain(); - return cdnDomain != null && !cdnDomain.isBlank() && cdnDomain.equalsIgnoreCase(uri.getHost()); - } catch (IllegalArgumentException exception) { - return false; - } - } - - public enum PresignUploadStatus { - SUCCESS, - INVALID_TOKEN, - STORAGE_NOT_CONFIGURED, - SERVER_NOT_FOUND, - VALIDATION_FAILED, - QUOTA_EXCEEDED - } - - public enum ConfirmUploadStatus { - SUCCESS, - INVALID_TOKEN, - INVALID_KEY, - UPLOAD_NOT_FOUND, - QUOTA_EXCEEDED, - RECORD_FAILED - } - - public enum SubmitEvidenceStatus { - SUCCESS, - INVALID_TOKEN, - SERVER_NOT_FOUND, - INVALID_URL, - PUNISHMENT_NOT_FOUND - } - - public record TokenValidationResult(boolean valid, TokenInfo info) { - private static TokenValidationResult invalid() { - return new TokenValidationResult(false, null); - } - - private static TokenValidationResult valid(TokenInfo info) { - return new TokenValidationResult(true, info); - } - } - - public record TokenInfo(String punishmentId, String playerName, String issuerName) { - } - - public record PresignUploadResult(PresignUploadStatus status, String message, PresignUploadResponse upload) { - private static PresignUploadResult of(PresignUploadStatus status, String message, PresignUploadResponse upload) { - return new PresignUploadResult(status, message, upload); - } - } - - public record ConfirmUploadResult(ConfirmUploadStatus status, UploadResponse upload) { - private static ConfirmUploadResult of(ConfirmUploadStatus status, UploadResponse upload) { - return new ConfirmUploadResult(status, upload); - } - } - - public record SubmitEvidenceResult(SubmitEvidenceStatus status, String message) { - private static SubmitEvidenceResult of(SubmitEvidenceStatus status, String message) { - return new SubmitEvidenceResult(status, message); - } - - public HttpStatus httpStatus() { - return switch (status) { - case SUCCESS -> HttpStatus.OK; - case INVALID_TOKEN, SERVER_NOT_FOUND, PUNISHMENT_NOT_FOUND -> HttpStatus.NOT_FOUND; - case INVALID_URL -> HttpStatus.BAD_REQUEST; - }; - } - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } -} +package gg.modl.backend.storage.service; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.service.PlayerDataUtils; +import gg.modl.backend.player.service.PunishmentEvidenceService; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.player.service.PunishmentQueryService.UploadedEvidenceItem; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.storage.dto.request.EvidenceConfirmUploadRequest; +import gg.modl.backend.storage.dto.request.EvidenceItemRequest; +import gg.modl.backend.storage.dto.request.EvidencePresignUploadRequest; +import gg.modl.backend.storage.dto.request.SubmitEvidenceRequest; +import gg.modl.backend.storage.data.StorageFileDocument; +import gg.modl.backend.storage.dto.response.PresignUploadResponse; +import gg.modl.backend.storage.dto.response.UploadResponse; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class EvidenceUploadService { + private final EvidenceUploadTokenService tokenService; + private final S3StorageService s3StorageService; + private final PlayerMongoRepository playerRepository; + private final ServerService serverService; + private final MediaValidationService validationService; + private final PunishmentEvidenceService punishmentEvidenceService; + private final StorageMetadataService storageMetadataService; + private final UploadOrchestrationService uploadOrchestrationService; + + public TokenValidationResult validateToken(String token) { + EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); + if (uploadToken == null) { + return TokenValidationResult.invalid(); + } + + Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); + Player player = server == null + ? null + : playerRepository.findByMinecraftUuid(server, normalizeUuid(uploadToken.playerUuid())).orElse(null); + String playerName = player != null ? PlayerDataUtils.extractLatestUsername(player.getUsernames()) : "Unknown"; + + return TokenValidationResult.valid(new TokenInfo( + uploadToken.punishmentId(), + playerName, + uploadToken.issuerName() + )); + } + + public PresignUploadResult presignUpload(String token, EvidencePresignUploadRequest request) { + EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); + if (uploadToken == null) { + return PresignUploadResult.of(PresignUploadStatus.INVALID_TOKEN, null, null); + } + + if (!s3StorageService.isConfigured()) { + return PresignUploadResult.of(PresignUploadStatus.STORAGE_NOT_CONFIGURED, null, null); + } + + Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); + if (server == null) { + return PresignUploadResult.of(PresignUploadStatus.SERVER_NOT_FOUND, null, null); + } + + UploadOrchestrationService.PresignOutcome outcome = uploadOrchestrationService.presign(server, + new UploadOrchestrationService.UploadPresignRequest( + "evidence", + request.fileName(), + request.contentType(), + request.fileSize(), + uploadToken.punishmentId(), + server.getPlan() == ServerPlan.PREMIUM, + false + )); + + return switch (outcome.status()) { + case SUCCESS -> PresignUploadResult.of(PresignUploadStatus.SUCCESS, null, outcome.upload()); + case QUOTA_EXCEEDED -> PresignUploadResult.of(PresignUploadStatus.QUOTA_EXCEEDED, + "Storage quota exceeded. Please contact the server administrator.", null); + case VALIDATION_FAILED, TEMP_LIMIT_EXCEEDED -> + PresignUploadResult.of(PresignUploadStatus.VALIDATION_FAILED, outcome.message(), null); + }; + } + + public ConfirmUploadResult confirmUpload(String token, EvidenceConfirmUploadRequest request) { + EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); + if (uploadToken == null) { + return ConfirmUploadResult.of(ConfirmUploadStatus.INVALID_TOKEN, null); + } + + if (!validationService.isKeyOwnedByServer(request.key(), uploadToken.serverDatabaseName())) { + return ConfirmUploadResult.of(ConfirmUploadStatus.INVALID_KEY, null); + } + + String expectedKeyPrefix = uploadToken.serverDatabaseName() + "/evidence/" + uploadToken.punishmentId() + "/"; + if (!request.key().startsWith(expectedKeyPrefix)) { + return ConfirmUploadResult.of(ConfirmUploadStatus.INVALID_KEY, null); + } + + Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); + if (server == null) { + UploadResponse uploadDetails = s3StorageService.getUploadDetails(request.key()); + if (uploadDetails == null) { + return ConfirmUploadResult.of(ConfirmUploadStatus.UPLOAD_NOT_FOUND, null); + } + log.warn("Could not record storage metadata: server not found for database {}", uploadToken.serverDatabaseName()); + return ConfirmUploadResult.of(ConfirmUploadStatus.SUCCESS, uploadDetails); + } + + UploadOrchestrationService.ConfirmOutcome outcome = + uploadOrchestrationService.confirm(server, request.key(), false); + return switch (outcome.status()) { + case SUCCESS -> ConfirmUploadResult.of(ConfirmUploadStatus.SUCCESS, outcome.upload()); + case UPLOAD_NOT_FOUND -> ConfirmUploadResult.of(ConfirmUploadStatus.UPLOAD_NOT_FOUND, null); + case QUOTA_EXCEEDED -> ConfirmUploadResult.of(ConfirmUploadStatus.QUOTA_EXCEEDED, null); + case RECORD_FAILED, TEMP_LIMIT_EXCEEDED -> ConfirmUploadResult.of(ConfirmUploadStatus.RECORD_FAILED, null); + }; + } + + public SubmitEvidenceResult submitEvidence(String token, SubmitEvidenceRequest request) { + EvidenceUploadTokenService.UploadToken uploadToken = tokenService.validateToken(token); + if (uploadToken == null) { + return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_TOKEN, null); + } + + Server server = serverService.getServerByDatabaseName(uploadToken.serverDatabaseName()); + if (server == null) { + return SubmitEvidenceResult.of(SubmitEvidenceStatus.SERVER_NOT_FOUND, null); + } + + List items = request.evidence(); + List keys = new ArrayList<>(items.size()); + for (EvidenceItemRequest item : items) { + if (!isAllowedEvidenceUrl(item.url(), uploadToken)) { + return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_URL, null); + } + + String key = extractKeyFromEvidenceUrl(item.url()); + if (key == null) { + return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_URL, null); + } + keys.add(key); + } + + Map confirmedFiles = storageMetadataService.findConfirmedFiles(server, keys); + + List evidenceItems = new ArrayList<>(items.size()); + for (int i = 0; i < items.size(); i++) { + EvidenceItemRequest item = items.get(i); + StorageFileDocument doc = confirmedFiles.get(keys.get(i)); + if (doc == null) { + return SubmitEvidenceResult.of(SubmitEvidenceStatus.INVALID_URL, null); + } + + evidenceItems.add(new UploadedEvidenceItem( + item.url(), + item.fileName(), + doc.getContentType(), + doc.getSize() + )); + } + + PunishmentOperationResult result = punishmentEvidenceService.addUploadedEvidence( + server, + uploadToken.punishmentId(), + uploadToken.issuerName(), + null, + evidenceItems + ); + if (result.status() == PunishmentOperationStatus.NOT_FOUND) { + return SubmitEvidenceResult.of(SubmitEvidenceStatus.PUNISHMENT_NOT_FOUND, result.message()); + } + + tokenService.invalidateToken(token); + return SubmitEvidenceResult.of(SubmitEvidenceStatus.SUCCESS, null); + } + + private String extractKeyFromEvidenceUrl(String url) { + try { + String path = URI.create(url).getPath(); + if (path == null || path.isBlank()) { + return null; + } + return path.startsWith("/") ? path.substring(1) : path; + } catch (IllegalArgumentException exception) { + return null; + } + } + + private boolean isAllowedEvidenceUrl(String url, EvidenceUploadTokenService.UploadToken uploadToken) { + if (url == null || url.isBlank()) { + return false; + } + + try { + URI uri = URI.create(url); + String scheme = uri.getScheme(); + if (!"https".equalsIgnoreCase(scheme) && !"http".equalsIgnoreCase(scheme)) { + return false; + } + + String expectedPathFragment = "/" + uploadToken.serverDatabaseName() + "/evidence/" + uploadToken.punishmentId() + "/"; + String path = uri.getPath(); + if (path == null || !path.contains(expectedPathFragment)) { + return false; + } + + String cdnDomain = s3StorageService.getCdnDomain(); + return cdnDomain != null && !cdnDomain.isBlank() && cdnDomain.equalsIgnoreCase(uri.getHost()); + } catch (IllegalArgumentException exception) { + return false; + } + } + + public enum PresignUploadStatus { + SUCCESS, + INVALID_TOKEN, + STORAGE_NOT_CONFIGURED, + SERVER_NOT_FOUND, + VALIDATION_FAILED, + QUOTA_EXCEEDED + } + + public enum ConfirmUploadStatus { + SUCCESS, + INVALID_TOKEN, + INVALID_KEY, + UPLOAD_NOT_FOUND, + QUOTA_EXCEEDED, + RECORD_FAILED + } + + public enum SubmitEvidenceStatus { + SUCCESS, + INVALID_TOKEN, + SERVER_NOT_FOUND, + INVALID_URL, + PUNISHMENT_NOT_FOUND + } + + public record TokenValidationResult(boolean valid, TokenInfo info) { + private static TokenValidationResult invalid() { + return new TokenValidationResult(false, null); + } + + private static TokenValidationResult valid(TokenInfo info) { + return new TokenValidationResult(true, info); + } + } + + public record TokenInfo(String punishmentId, String playerName, String issuerName) { + } + + public record PresignUploadResult(PresignUploadStatus status, String message, PresignUploadResponse upload) { + private static PresignUploadResult of(PresignUploadStatus status, String message, PresignUploadResponse upload) { + return new PresignUploadResult(status, message, upload); + } + } + + public record ConfirmUploadResult(ConfirmUploadStatus status, UploadResponse upload) { + private static ConfirmUploadResult of(ConfirmUploadStatus status, UploadResponse upload) { + return new ConfirmUploadResult(status, upload); + } + } + + public record SubmitEvidenceResult(SubmitEvidenceStatus status, String message) { + private static SubmitEvidenceResult of(SubmitEvidenceStatus status, String message) { + return new SubmitEvidenceResult(status, message); + } + + public HttpStatus httpStatus() { + return switch (status) { + case SUCCESS -> HttpStatus.OK; + case INVALID_TOKEN, SERVER_NOT_FOUND, PUNISHMENT_NOT_FOUND -> HttpStatus.NOT_FOUND; + case INVALID_URL -> HttpStatus.BAD_REQUEST; + }; + } + } + + private static String normalizeUuid(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/gg/modl/backend/storage/service/MediaAccessService.java b/src/main/java/gg/modl/backend/storage/service/MediaAccessService.java index df541ae..4d5cd06 100644 --- a/src/main/java/gg/modl/backend/storage/service/MediaAccessService.java +++ b/src/main/java/gg/modl/backend/storage/service/MediaAccessService.java @@ -1,91 +1,91 @@ -package gg.modl.backend.storage.service; - -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.service.TicketEmailVerificationService; -import gg.modl.backend.ticket.service.TicketService; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MediaAccessService { - private final TicketService ticketService; - private final TicketEmailVerificationService verificationService; - - public AccessResult validatePublicUploadAccess( - Server server, - String uploadType, - String entityId, - String accessToken - ) { - if (entityId == null || entityId.isBlank()) { - return AccessResult.denied("entityId is required for public uploads"); - } - - String normalizedEntityId = entityId.trim(); - String normalizedType = normalizeUploadType(uploadType); - - if ("new".equalsIgnoreCase(normalizedEntityId)) { - if ("ticket".equals(normalizedType) || "appeal".equals(normalizedType)) { - return AccessResult.allowed(); - } - return AccessResult.denied("Temporary uploads are only allowed for ticket and appeal types"); - } - - Optional ticketOpt = ticketService.getTicketRaw(server, normalizedEntityId); - if (ticketOpt.isEmpty() || ticketOpt.get().isHidden()) { - return AccessResult.notFound(); - } - - Ticket ticket = ticketOpt.get(); - boolean isAppealTicket = ticket.getType() == TicketCategory.APPEAL; - if ("appeal".equals(normalizedType) && !isAppealTicket) { - return AccessResult.denied("Entity is not an appeal ticket"); - } - if ("ticket".equals(normalizedType) && isAppealTicket) { - return AccessResult.denied("Appeal uploads must use uploadType=appeal"); - } - - if (ticket.isEmailAuthEnabled()) { - boolean validToken = accessToken != null - && !accessToken.isBlank() - && verificationService.validateToken(server, normalizedEntityId, accessToken); - if (!validToken) { - return AccessResult.denied("Email verification token required for this ticket"); - } - } - - return AccessResult.allowed(); - } - - private String normalizeUploadType(String uploadType) { - return "tickets".equals(uploadType) ? "ticket" : uploadType; - } - - public enum AccessStatus { - ALLOWED, - DENIED, - NOT_FOUND - } - - public record AccessResult(AccessStatus status, String error) { - static AccessResult allowed() { - return new AccessResult(AccessStatus.ALLOWED, null); - } - - static AccessResult denied(String error) { - return new AccessResult(AccessStatus.DENIED, error); - } - - static AccessResult notFound() { - return new AccessResult(AccessStatus.NOT_FOUND, null); - } - - public boolean isAllowed() { - return status == AccessStatus.ALLOWED; - } - } -} +package gg.modl.backend.storage.service; + +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.service.TicketEmailVerificationService; +import gg.modl.backend.ticket.service.TicketService; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MediaAccessService { + private final TicketService ticketService; + private final TicketEmailVerificationService verificationService; + + public AccessResult validatePublicUploadAccess( + Server server, + String uploadType, + String entityId, + String accessToken + ) { + if (entityId == null || entityId.isBlank()) { + return AccessResult.denied("entityId is required for public uploads"); + } + + String normalizedEntityId = entityId.trim(); + String normalizedType = normalizeUploadType(uploadType); + + if ("new".equalsIgnoreCase(normalizedEntityId)) { + if ("ticket".equals(normalizedType) || "appeal".equals(normalizedType)) { + return AccessResult.allowed(); + } + return AccessResult.denied("Temporary uploads are only allowed for ticket and appeal types"); + } + + Optional ticketOpt = ticketService.getTicketRaw(server, normalizedEntityId); + if (ticketOpt.isEmpty() || ticketOpt.get().isHidden()) { + return AccessResult.notFound(); + } + + Ticket ticket = ticketOpt.get(); + boolean isAppealTicket = ticket.getType() == TicketCategory.APPEAL; + if ("appeal".equals(normalizedType) && !isAppealTicket) { + return AccessResult.denied("Entity is not an appeal ticket"); + } + if ("ticket".equals(normalizedType) && isAppealTicket) { + return AccessResult.denied("Appeal uploads must use uploadType=appeal"); + } + + if (ticket.isEmailAuthEnabled()) { + boolean validToken = accessToken != null + && !accessToken.isBlank() + && verificationService.validateToken(server, normalizedEntityId, accessToken); + if (!validToken) { + return AccessResult.denied("Email verification token required for this ticket"); + } + } + + return AccessResult.allowed(); + } + + private String normalizeUploadType(String uploadType) { + return "tickets".equals(uploadType) ? "ticket" : uploadType; + } + + public enum AccessStatus { + ALLOWED, + DENIED, + NOT_FOUND + } + + public record AccessResult(AccessStatus status, String error) { + static AccessResult allowed() { + return new AccessResult(AccessStatus.ALLOWED, null); + } + + static AccessResult denied(String error) { + return new AccessResult(AccessStatus.DENIED, error); + } + + static AccessResult notFound() { + return new AccessResult(AccessStatus.NOT_FOUND, null); + } + + public boolean isAllowed() { + return status == AccessStatus.ALLOWED; + } + } +} diff --git a/src/main/java/gg/modl/backend/storage/service/MediaValidationService.java b/src/main/java/gg/modl/backend/storage/service/MediaValidationService.java index ffa43db..30aa27d 100644 --- a/src/main/java/gg/modl/backend/storage/service/MediaValidationService.java +++ b/src/main/java/gg/modl/backend/storage/service/MediaValidationService.java @@ -1,168 +1,168 @@ -package gg.modl.backend.storage.service; - -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.util.ByteFormatUtil; -import gg.modl.backend.server.data.Server; -import java.util.List; -import java.util.Map; -import java.util.Set; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@Slf4j -public class MediaValidationService { - private static final long DEFAULT_MAX_FILE_SIZE = 10L * 1024 * 1024; // 10 MB - - private static final Set DANGEROUS_EXTENSIONS = Set.of( - ".exe", ".bat", ".cmd", ".com", ".msi", ".scr", ".pif", - ".js", ".vbs", ".wsf", ".jar", ".sh", ".ps1", ".php", - ".asp", ".aspx", ".jsp", ".cgi", ".pl", ".py", ".rb" - ); - - private static final Map> ALLOWED_TYPES = Map.of( - "evidence", Set.of("image/png", "image/jpeg", "image/gif", "image/apng", "image/webp", "video/mp4", "video/webm", "video/quicktime", - "video/x-matroska", "application/pdf", "text/plain", "text/markdown"), - "ticket", Set.of("image/png", "image/jpeg", "image/gif", "image/apng", "image/webp", "video/mp4", "video/webm", "video/quicktime", - "video/x-matroska", "application/pdf", "text/plain", "text/markdown"), - "appeal", Set.of("image/png", "image/jpeg", "image/gif", "image/apng", "image/webp", "video/mp4", "video/webm", "video/quicktime", - "video/x-matroska", "application/pdf", "text/plain", "text/markdown"), - "article", Set.of("image/png", "image/jpeg", "image/gif", "image/webp", "image/apng", "text/plain", "text/markdown"), - "server-icon", Set.of("image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"), - "replay", Set.of("application/octet-stream") - ); - - private static final Map MAX_SIZES = Map.of( - "evidence", 100L * 1024 * 1024, // 100 mb - "ticket", 100L * 1024 * 1024, // 100 mb - "appeal", 100L * 1024 * 1024, // 100 mb - "article", 50L * 1024 * 1024, // 50 mb - "server-icon", 10L * 1024 * 1024, // 10 mb - "replay", 10L * 1024 * 1024 // 10 mb - ); - - private static final Map PREMIUM_MAX_SIZES = Map.of( - "evidence", 1L * 1024 * 1024 * 1024, // 1 GB - "ticket", 1L * 1024 * 1024 * 1024, // 1 GB - "appeal", 1L * 1024 * 1024 * 1024, // 1 GB - "article", 50L * 1024 * 1024, // 50 mb - "server-icon", 10L * 1024 * 1024, // 10 mb - "replay", 10L * 1024 * 1024 // 10 mb - ); - - public ValidationResult validateMetadata(String fileName, String contentType, long fileSize, String uploadType) { - return validateMetadata(fileName, contentType, fileSize, uploadType, false); - } - - public ValidationResult validateMetadata(String fileName, String contentType, long fileSize, String uploadType, boolean isPremium) { - if (fileName == null || fileName.isBlank()) { - return new ValidationResult(false, "File name is required"); - } - - if (contentType == null || contentType.isBlank()) { - return new ValidationResult(false, "Content type is required"); - } - - String lowerName = fileName.toLowerCase(); - for (String ext : DANGEROUS_EXTENSIONS) { - if (lowerName.endsWith(ext)) { - log.warn("Blocked presign request for potentially dangerous file: {}", fileName); - return new ValidationResult(false, "File type not allowed"); - } - } - - Set allowedTypes = getAllowedTypes(uploadType); - if (allowedTypes.isEmpty()) { - return new ValidationResult(false, "Invalid upload type"); - } - - if (!allowedTypes.contains(contentType)) { - return new ValidationResult(false, "File type not allowed for " + uploadType + ". Allowed: " + allowedTypes); - } - - long maxSize = getMaxSize(uploadType, isPremium); - if (fileSize > maxSize) { - return new ValidationResult(false, "File exceeds maximum size of " + ByteFormatUtil.formatCompact(maxSize)); - } - - if (fileSize <= 0) { - return new ValidationResult(false, "Invalid file size"); - } - - return new ValidationResult(true, null); - } - - public Set getAllowedTypes(String uploadType) { - return ALLOWED_TYPES.getOrDefault(uploadType, Set.of()); - } - - public long getMaxSize(String uploadType, boolean isPremium) { - Map sizes = isPremium ? PREMIUM_MAX_SIZES : MAX_SIZES; - return sizes.getOrDefault(uploadType, DEFAULT_MAX_FILE_SIZE); - } - - public long getMaxSize(String uploadType) { - return MAX_SIZES.getOrDefault(uploadType, DEFAULT_MAX_FILE_SIZE); - } - - public Map getAllSupportedTypes() { - return Map.of( - "evidence", List.copyOf(ALLOWED_TYPES.get("evidence")), - "tickets", List.copyOf(ALLOWED_TYPES.get("ticket")), - "appeals", List.copyOf(ALLOWED_TYPES.get("appeal")), - "articles", List.copyOf(ALLOWED_TYPES.get("article")), - "server-icons", List.copyOf(ALLOWED_TYPES.get("server-icon")), - "replays", List.copyOf(ALLOWED_TYPES.get("replay")) - ); - } - - public Map getAllSizeLimits() { - return getAllSizeLimits(false); - } - - public Map getAllSizeLimits(boolean isPremium) { - Map sizes = isPremium ? PREMIUM_MAX_SIZES : MAX_SIZES; - return Map.of( - "evidence", sizes.get("evidence"), - "tickets", sizes.get("ticket"), - "appeals", sizes.get("appeal"), - "articles", sizes.get("article"), - "server-icons", sizes.get("server-icon"), - "replays", sizes.get("replay") - ); - } - - public boolean isKeyOwnedByServer(String key, String serverDatabaseName) { - if (key == null || serverDatabaseName == null || !key.startsWith(serverDatabaseName + "/")) { - return false; - } - return !hasPathTraversal(key); - } - - public void assertKeyOwnedByServer(Server server, String key) { - if (server == null || !isKeyOwnedByServer(key, server.getDatabaseName())) { - throw new ForbiddenException("Access denied"); - } - } - - private boolean hasPathTraversal(String key) { - for (String segment : key.split("/")) { - if (segment.equals("..")) { - return true; - } - } - return false; - } - - public String extractUploadType(String key) { - String[] parts = key.split("/"); - return parts.length >= 2 ? parts[1] : ""; - } - - public String extractEntityId(String key) { - String[] parts = key.split("/"); - return parts.length >= 4 ? parts[2] : null; - } - - public record ValidationResult(boolean valid, String error) {} -} +package gg.modl.backend.storage.service; + +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.util.ByteFormatUtil; +import gg.modl.backend.server.data.Server; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class MediaValidationService { + private static final long DEFAULT_MAX_FILE_SIZE = 10L * 1024 * 1024; // 10 MB + + private static final Set DANGEROUS_EXTENSIONS = Set.of( + ".exe", ".bat", ".cmd", ".com", ".msi", ".scr", ".pif", + ".js", ".vbs", ".wsf", ".jar", ".sh", ".ps1", ".php", + ".asp", ".aspx", ".jsp", ".cgi", ".pl", ".py", ".rb" + ); + + private static final Map> ALLOWED_TYPES = Map.of( + "evidence", Set.of("image/png", "image/jpeg", "image/gif", "image/apng", "image/webp", "video/mp4", "video/webm", "video/quicktime", + "video/x-matroska", "application/pdf", "text/plain", "text/markdown"), + "ticket", Set.of("image/png", "image/jpeg", "image/gif", "image/apng", "image/webp", "video/mp4", "video/webm", "video/quicktime", + "video/x-matroska", "application/pdf", "text/plain", "text/markdown"), + "appeal", Set.of("image/png", "image/jpeg", "image/gif", "image/apng", "image/webp", "video/mp4", "video/webm", "video/quicktime", + "video/x-matroska", "application/pdf", "text/plain", "text/markdown"), + "article", Set.of("image/png", "image/jpeg", "image/gif", "image/webp", "image/apng", "text/plain", "text/markdown"), + "server-icon", Set.of("image/png", "image/jpeg", "image/webp", "image/gif", "image/apng"), + "replay", Set.of("application/octet-stream") + ); + + private static final Map MAX_SIZES = Map.of( + "evidence", 100L * 1024 * 1024, // 100 mb + "ticket", 100L * 1024 * 1024, // 100 mb + "appeal", 100L * 1024 * 1024, // 100 mb + "article", 50L * 1024 * 1024, // 50 mb + "server-icon", 10L * 1024 * 1024, // 10 mb + "replay", 10L * 1024 * 1024 // 10 mb + ); + + private static final Map PREMIUM_MAX_SIZES = Map.of( + "evidence", 1L * 1024 * 1024 * 1024, // 1 GB + "ticket", 1L * 1024 * 1024 * 1024, // 1 GB + "appeal", 1L * 1024 * 1024 * 1024, // 1 GB + "article", 50L * 1024 * 1024, // 50 mb + "server-icon", 10L * 1024 * 1024, // 10 mb + "replay", 10L * 1024 * 1024 // 10 mb + ); + + public ValidationResult validateMetadata(String fileName, String contentType, long fileSize, String uploadType) { + return validateMetadata(fileName, contentType, fileSize, uploadType, false); + } + + public ValidationResult validateMetadata(String fileName, String contentType, long fileSize, String uploadType, boolean isPremium) { + if (fileName == null || fileName.isBlank()) { + return new ValidationResult(false, "File name is required"); + } + + if (contentType == null || contentType.isBlank()) { + return new ValidationResult(false, "Content type is required"); + } + + String lowerName = fileName.toLowerCase(); + for (String ext : DANGEROUS_EXTENSIONS) { + if (lowerName.endsWith(ext)) { + log.warn("Blocked presign request for potentially dangerous file: {}", fileName); + return new ValidationResult(false, "File type not allowed"); + } + } + + Set allowedTypes = getAllowedTypes(uploadType); + if (allowedTypes.isEmpty()) { + return new ValidationResult(false, "Invalid upload type"); + } + + if (!allowedTypes.contains(contentType)) { + return new ValidationResult(false, "File type not allowed for " + uploadType + ". Allowed: " + allowedTypes); + } + + long maxSize = getMaxSize(uploadType, isPremium); + if (fileSize > maxSize) { + return new ValidationResult(false, "File exceeds maximum size of " + ByteFormatUtil.formatCompact(maxSize)); + } + + if (fileSize <= 0) { + return new ValidationResult(false, "Invalid file size"); + } + + return new ValidationResult(true, null); + } + + public Set getAllowedTypes(String uploadType) { + return ALLOWED_TYPES.getOrDefault(uploadType, Set.of()); + } + + public long getMaxSize(String uploadType, boolean isPremium) { + Map sizes = isPremium ? PREMIUM_MAX_SIZES : MAX_SIZES; + return sizes.getOrDefault(uploadType, DEFAULT_MAX_FILE_SIZE); + } + + public long getMaxSize(String uploadType) { + return MAX_SIZES.getOrDefault(uploadType, DEFAULT_MAX_FILE_SIZE); + } + + public Map getAllSupportedTypes() { + return Map.of( + "evidence", List.copyOf(ALLOWED_TYPES.get("evidence")), + "tickets", List.copyOf(ALLOWED_TYPES.get("ticket")), + "appeals", List.copyOf(ALLOWED_TYPES.get("appeal")), + "articles", List.copyOf(ALLOWED_TYPES.get("article")), + "server-icons", List.copyOf(ALLOWED_TYPES.get("server-icon")), + "replays", List.copyOf(ALLOWED_TYPES.get("replay")) + ); + } + + public Map getAllSizeLimits() { + return getAllSizeLimits(false); + } + + public Map getAllSizeLimits(boolean isPremium) { + Map sizes = isPremium ? PREMIUM_MAX_SIZES : MAX_SIZES; + return Map.of( + "evidence", sizes.get("evidence"), + "tickets", sizes.get("ticket"), + "appeals", sizes.get("appeal"), + "articles", sizes.get("article"), + "server-icons", sizes.get("server-icon"), + "replays", sizes.get("replay") + ); + } + + public boolean isKeyOwnedByServer(String key, String serverDatabaseName) { + if (key == null || serverDatabaseName == null || !key.startsWith(serverDatabaseName + "/")) { + return false; + } + return !hasPathTraversal(key); + } + + public void assertKeyOwnedByServer(Server server, String key) { + if (server == null || !isKeyOwnedByServer(key, server.getDatabaseName())) { + throw new ForbiddenException("Access denied"); + } + } + + private boolean hasPathTraversal(String key) { + for (String segment : key.split("/")) { + if (segment.equals("..")) { + return true; + } + } + return false; + } + + public String extractUploadType(String key) { + String[] parts = key.split("/"); + return parts.length >= 2 ? parts[1] : ""; + } + + public String extractEntityId(String key) { + String[] parts = key.split("/"); + return parts.length >= 4 ? parts[2] : null; + } + + public record ValidationResult(boolean valid, String error) {} +} diff --git a/src/main/java/gg/modl/backend/ticket/controller/MinecraftReportsController.java b/src/main/java/gg/modl/backend/ticket/controller/MinecraftReportsController.java index 4a38620..0394f10 100644 --- a/src/main/java/gg/modl/backend/ticket/controller/MinecraftReportsController.java +++ b/src/main/java/gg/modl/backend/ticket/controller/MinecraftReportsController.java @@ -1,132 +1,132 @@ -package gg.modl.backend.ticket.controller; - -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.dto.request.AssignReportRequest; -import gg.modl.backend.ticket.dto.request.DismissReportRequest; -import gg.modl.backend.ticket.dto.request.ResolveReportRequest; -import gg.modl.backend.ticket.service.MinecraftTicketService; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@Slf4j -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_REPORTS) -@RequiredArgsConstructor -@Validated -public class MinecraftReportsController { - private final MinecraftTicketService minecraftTicketService; - - @GetMapping - public ResponseEntity> getAllReports( - @RequestParam(defaultValue = "open") String status, - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - List> reports = minecraftTicketService.getMinecraftReports(server, status, limit); - return ResponseEntity.ok(Map.of( - "status", 200, - "reports", reports - )); - } - - @PostMapping("/{id}/dismiss") - public ResponseEntity> dismissReport( - @PathVariable String id, - @RequestBody @Valid DismissReportRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftTicketService.ReportOperationResult result = minecraftTicketService.dismissMinecraftReport(server, id, request); - if (result.status() == MinecraftTicketService.ReportOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Report not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", "Report dismissed" - )); - } - - @PostMapping("/{id}/resolve") - public ResponseEntity> resolveReport( - @PathVariable String id, - @RequestBody @Valid ResolveReportRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftTicketService.ReportOperationResult result = minecraftTicketService.resolveMinecraftReport(server, id, request); - if (result.status() == MinecraftTicketService.ReportOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Report not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", "Report resolved" - )); - } - - @GetMapping("/player/{uuid}") - public ResponseEntity> getPlayerReports( - @PathVariable String uuid, - @RequestParam(defaultValue = "all") String status, - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - List> reports = minecraftTicketService.getMinecraftReportsForPlayer(server, uuid, status, limit); - return ResponseEntity.ok(Map.of( - "status", 200, - "reports", reports - )); - } - - @PostMapping("/{id}/assign") - public ResponseEntity> assignReport( - @PathVariable String id, - @RequestBody @Valid AssignReportRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftTicketService.ReportOperationResult result = minecraftTicketService.assignMinecraftReport(server, id, request); - if (result.status() == MinecraftTicketService.ReportOperationStatus.NOT_FOUND) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Report not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "message", "Report assigned" - )); - } -} +package gg.modl.backend.ticket.controller; + +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.dto.request.AssignReportRequest; +import gg.modl.backend.ticket.dto.request.DismissReportRequest; +import gg.modl.backend.ticket.dto.request.ResolveReportRequest; +import gg.modl.backend.ticket.service.MinecraftTicketService; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@Slf4j +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_REPORTS) +@RequiredArgsConstructor +@Validated +public class MinecraftReportsController { + private final MinecraftTicketService minecraftTicketService; + + @GetMapping + public ResponseEntity> getAllReports( + @RequestParam(defaultValue = "open") String status, + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + List> reports = minecraftTicketService.getMinecraftReports(server, status, limit); + return ResponseEntity.ok(Map.of( + "status", 200, + "reports", reports + )); + } + + @PostMapping("/{id}/dismiss") + public ResponseEntity> dismissReport( + @PathVariable String id, + @RequestBody @Valid DismissReportRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftTicketService.ReportOperationResult result = minecraftTicketService.dismissMinecraftReport(server, id, request); + if (result.status() == MinecraftTicketService.ReportOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Report not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", "Report dismissed" + )); + } + + @PostMapping("/{id}/resolve") + public ResponseEntity> resolveReport( + @PathVariable String id, + @RequestBody @Valid ResolveReportRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftTicketService.ReportOperationResult result = minecraftTicketService.resolveMinecraftReport(server, id, request); + if (result.status() == MinecraftTicketService.ReportOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Report not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", "Report resolved" + )); + } + + @GetMapping("/player/{uuid}") + public ResponseEntity> getPlayerReports( + @PathVariable String uuid, + @RequestParam(defaultValue = "all") String status, + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + List> reports = minecraftTicketService.getMinecraftReportsForPlayer(server, uuid, status, limit); + return ResponseEntity.ok(Map.of( + "status", 200, + "reports", reports + )); + } + + @PostMapping("/{id}/assign") + public ResponseEntity> assignReport( + @PathVariable String id, + @RequestBody @Valid AssignReportRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftTicketService.ReportOperationResult result = minecraftTicketService.assignMinecraftReport(server, id, request); + if (result.status() == MinecraftTicketService.ReportOperationStatus.NOT_FOUND) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Report not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", "Report assigned" + )); + } +} diff --git a/src/main/java/gg/modl/backend/ticket/controller/MinecraftTicketsController.java b/src/main/java/gg/modl/backend/ticket/controller/MinecraftTicketsController.java index 344280f..fff4fac 100644 --- a/src/main/java/gg/modl/backend/ticket/controller/MinecraftTicketsController.java +++ b/src/main/java/gg/modl/backend/ticket/controller/MinecraftTicketsController.java @@ -1,196 +1,196 @@ -package gg.modl.backend.ticket.controller; - -import gg.modl.backend.ai.service.AITicketAnalysisService; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.dto.request.MinecraftClaimTicketRequest; -import gg.modl.backend.ticket.dto.request.MinecraftCreateTicketRequest; -import gg.modl.backend.ticket.dto.request.MinecraftTicketsByIdsRequest; -import gg.modl.backend.ticket.service.MinecraftTicketService; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@Validated -@Slf4j -@RestController -@RequestMapping(RESTMappingV1.MINECRAFT_TICKETS) -@RequiredArgsConstructor -public class MinecraftTicketsController { - private final MinecraftTicketService minecraftTicketService; - private final AITicketAnalysisService aiTicketAnalysisService; - - @PostMapping - public ResponseEntity> createTicket( - @RequestBody @Valid MinecraftCreateTicketRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Ticket ticket = minecraftTicketService.createMinecraftTicket(server, request); - - if (TicketCategory.fromCanonicalId(request.type()) == TicketCategory.CHAT - && request.chatMessages() != null - && !request.chatMessages().isEmpty()) { - aiTicketAnalysisService.analyzeTicketAsync(server, ticket.getId()); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "ticketId", ticket.getId(), - "message", "Ticket created successfully" - )); - } - - @PostMapping("/unfinished") - public ResponseEntity> createUnfinishedTicket( - @RequestBody @Valid MinecraftCreateTicketRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Ticket ticket = minecraftTicketService.createUnfinishedMinecraftTicket(server, request); - - return ResponseEntity.ok(Map.of( - "status", 200, - "success", true, - "ticketId", ticket.getId(), - "message", "Ticket draft created - complete the form on the panel" - )); - } - - @GetMapping - public ResponseEntity> getAllTickets( - @RequestParam(required = false) String status, - @RequestParam(required = false) String type, - @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - List> ticketList = minecraftTicketService.getMinecraftTickets(server, status, type, limit) - .stream() - .map(minecraftTicketService::toTicketListItem) - .toList(); - - return ResponseEntity.ok(Map.of( - "status", 200, - "tickets", ticketList - )); - } - - @GetMapping("/{id}") - public ResponseEntity> getTicket( - @PathVariable String id, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - Ticket ticket = minecraftTicketService.getMinecraftTicket(server, id).orElse(null); - if (ticket == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "message", "Ticket not found" - )); - } - - return ResponseEntity.ok(Map.of( - "status", 200, - "ticket", minecraftTicketService.toTicketDetail(ticket) - )); - } - - @GetMapping("/player/{uuid}") - public ResponseEntity> getPlayerTickets( - @PathVariable String uuid, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - List> tickets = minecraftTicketService.getMinecraftTicketsByCreator(server, uuid, 50) - .stream() - .map(minecraftTicketService::toPlayerTicketItem) - .toList(); - - return ResponseEntity.ok(Map.of( - "status", 200, - "tickets", tickets - )); - } - - @PostMapping("/{id}/claim") - public ResponseEntity> claimTicket( - @PathVariable String id, - @RequestBody @Valid MinecraftClaimTicketRequest request, - HttpServletRequest httpRequest - ) { - Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftTicketService.MinecraftTicketClaimResult result = minecraftTicketService.claimMinecraftTicket(server, id, request); - - return switch (result.status()) { - case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "status", 404, - "success", false, - "message", "Ticket not found" - )); - case ALREADY_LINKED -> ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of( - "status", 409, - "success", false, - "message", "Ticket is already linked to a Minecraft account" - )); - case SUCCESS -> { - Map body = new LinkedHashMap<>(); - body.put("status", 200); - body.put("success", true); - body.put("message", "Ticket successfully linked to your account"); - body.put("ticketId", id); - String subject = result.ticket().getSubject(); - if (subject != null) { - body.put("subject", subject); - } - yield ResponseEntity.ok(body); - } - }; - } - - @PostMapping("/by-ids") - public ResponseEntity> getTicketsByIds( - @RequestBody @Valid MinecraftTicketsByIdsRequest request, - HttpServletRequest httpRequest - ) { - if (request.ids() == null || request.ids().isEmpty()) { - return ResponseEntity.ok(Map.of( - "status", 200, - "tickets", List.of() - )); - } - - Server server = RequestUtil.getRequestServer(httpRequest); - List> ticketList = minecraftTicketService.getMinecraftTicketsByIds(server, request.ids()) - .stream() - .map(minecraftTicketService::toTicketLookupItem) - .toList(); - - return ResponseEntity.ok(Map.of( - "status", 200, - "tickets", ticketList - )); - } - -} +package gg.modl.backend.ticket.controller; + +import gg.modl.backend.ai.service.AITicketAnalysisService; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.dto.request.MinecraftClaimTicketRequest; +import gg.modl.backend.ticket.dto.request.MinecraftCreateTicketRequest; +import gg.modl.backend.ticket.dto.request.MinecraftTicketsByIdsRequest; +import gg.modl.backend.ticket.service.MinecraftTicketService; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@Validated +@Slf4j +@RestController +@RequestMapping(RESTMappingV1.MINECRAFT_TICKETS) +@RequiredArgsConstructor +public class MinecraftTicketsController { + private final MinecraftTicketService minecraftTicketService; + private final AITicketAnalysisService aiTicketAnalysisService; + + @PostMapping + public ResponseEntity> createTicket( + @RequestBody @Valid MinecraftCreateTicketRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + Ticket ticket = minecraftTicketService.createMinecraftTicket(server, request); + + if (TicketCategory.fromCanonicalId(request.type()) == TicketCategory.CHAT + && request.chatMessages() != null + && !request.chatMessages().isEmpty()) { + aiTicketAnalysisService.analyzeTicketAsync(server, ticket.getId()); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "ticketId", ticket.getId(), + "message", "Ticket created successfully" + )); + } + + @PostMapping("/unfinished") + public ResponseEntity> createUnfinishedTicket( + @RequestBody @Valid MinecraftCreateTicketRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + Ticket ticket = minecraftTicketService.createUnfinishedMinecraftTicket(server, request); + + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "ticketId", ticket.getId(), + "message", "Ticket draft created - complete the form on the panel" + )); + } + + @GetMapping + public ResponseEntity> getAllTickets( + @RequestParam(required = false) String status, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "50") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + List> ticketList = minecraftTicketService.getMinecraftTickets(server, status, type, limit) + .stream() + .map(minecraftTicketService::toTicketListItem) + .toList(); + + return ResponseEntity.ok(Map.of( + "status", 200, + "tickets", ticketList + )); + } + + @GetMapping("/{id}") + public ResponseEntity> getTicket( + @PathVariable String id, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + Ticket ticket = minecraftTicketService.getMinecraftTicket(server, id).orElse(null); + if (ticket == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "message", "Ticket not found" + )); + } + + return ResponseEntity.ok(Map.of( + "status", 200, + "ticket", minecraftTicketService.toTicketDetail(ticket) + )); + } + + @GetMapping("/player/{uuid}") + public ResponseEntity> getPlayerTickets( + @PathVariable String uuid, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + List> tickets = minecraftTicketService.getMinecraftTicketsByCreator(server, uuid, 50) + .stream() + .map(minecraftTicketService::toPlayerTicketItem) + .toList(); + + return ResponseEntity.ok(Map.of( + "status", 200, + "tickets", tickets + )); + } + + @PostMapping("/{id}/claim") + public ResponseEntity> claimTicket( + @PathVariable String id, + @RequestBody @Valid MinecraftClaimTicketRequest request, + HttpServletRequest httpRequest + ) { + Server server = RequestUtil.getRequestServer(httpRequest); + MinecraftTicketService.MinecraftTicketClaimResult result = minecraftTicketService.claimMinecraftTicket(server, id, request); + + return switch (result.status()) { + case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "status", 404, + "success", false, + "message", "Ticket not found" + )); + case ALREADY_LINKED -> ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of( + "status", 409, + "success", false, + "message", "Ticket is already linked to a Minecraft account" + )); + case SUCCESS -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("success", true); + body.put("message", "Ticket successfully linked to your account"); + body.put("ticketId", id); + String subject = result.ticket().getSubject(); + if (subject != null) { + body.put("subject", subject); + } + yield ResponseEntity.ok(body); + } + }; + } + + @PostMapping("/by-ids") + public ResponseEntity> getTicketsByIds( + @RequestBody @Valid MinecraftTicketsByIdsRequest request, + HttpServletRequest httpRequest + ) { + if (request.ids() == null || request.ids().isEmpty()) { + return ResponseEntity.ok(Map.of( + "status", 200, + "tickets", List.of() + )); + } + + Server server = RequestUtil.getRequestServer(httpRequest); + List> ticketList = minecraftTicketService.getMinecraftTicketsByIds(server, request.ids()) + .stream() + .map(minecraftTicketService::toTicketLookupItem) + .toList(); + + return ResponseEntity.ok(Map.of( + "status", 200, + "tickets", ticketList + )); + } + +} diff --git a/src/main/java/gg/modl/backend/ticket/controller/PanelTicketController.java b/src/main/java/gg/modl/backend/ticket/controller/PanelTicketController.java index 34e1875..c01675b 100644 --- a/src/main/java/gg/modl/backend/ticket/controller/PanelTicketController.java +++ b/src/main/java/gg/modl/backend/ticket/controller/PanelTicketController.java @@ -1,258 +1,258 @@ -package gg.modl.backend.ticket.controller; - -import gg.modl.backend.infrastructure.exception.ValidationException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.log.service.PanelActionAuditor; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.request.BulkTicketUpdateRequest; -import gg.modl.backend.ticket.dto.request.CreateTicketRequest; -import gg.modl.backend.ticket.dto.request.QuickResponseRequest; -import gg.modl.backend.ticket.dto.request.UpdateTicketRequest; -import gg.modl.backend.ticket.dto.response.PaginatedTicketsResponse; -import gg.modl.backend.ticket.dto.response.QuickResponseResult; -import gg.modl.backend.ticket.dto.response.TicketResponse; -import gg.modl.backend.ticket.service.TicketReplyService; -import gg.modl.backend.ticket.service.TicketSearchService; -import gg.modl.backend.ticket.service.TicketService; -import gg.modl.backend.ticket.service.TicketSubscriptionService; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.proto.modl.v1.AddNoteRequest; -import gg.modl.proto.modl.v1.AddReplyRequest; -import gg.modl.proto.modl.v1.AddTagRequest; -import gg.modl.proto.modl.v1.AddTicketReplyResponse; -import gg.modl.proto.modl.v1.BulkTicketUpdateResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.TicketCountsResponse; -import gg.modl.proto.modl.v1.TicketNote; -import gg.modl.proto.modl.v1.TicketTagsResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_TICKETS) -@RequiredArgsConstructor -@Validated -public class PanelTicketController { - private final TicketService ticketService; - private final TicketSearchService ticketSearchService; - private final TicketReplyService ticketReplyService; - private final TicketSubscriptionService subscriptionService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final PanelActionAuditor panelActionAuditor; - - @GetMapping - public ResponseEntity searchTickets( - @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - @RequestParam(required = false) String search, - @RequestParam(required = false) String status, - @RequestParam(required = false) List type, - @RequestParam(required = false) String author, - @RequestParam(required = false) List labels, - @RequestParam(required = false) List assignee, - @RequestParam(defaultValue = "newest") String sort, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - PaginatedTicketsResponse response = ticketSearchService.searchTickets( - server, page, limit, search, status, type, author, labels, assignee, sort); - return ResponseEntity.ok(PanelTicketProtoMapper.toPaginatedTicketsResponse(response)); - } - - @GetMapping("/counts") - public ResponseEntity getTicketCounts( - @RequestParam(required = false) String search, - @RequestParam(required = false) List type, - @RequestParam(required = false) String author, - @RequestParam(required = false) List labels, - @RequestParam(required = false) List assignee, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - Map counts = ticketSearchService.getTicketCounts(server, search, type, author, labels, assignee); - return ResponseEntity.ok(PanelTicketProtoMapper.toTicketCountsResponse(counts)); - } - - @PostMapping("/bulk") - public ResponseEntity bulkUpdateTickets( - @RequestBody gg.modl.proto.modl.v1.BulkTicketUpdateRequest bulkRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - BulkTicketUpdateRequest command = PanelTicketProtoMapper.fromBulkTicketUpdateRequest(bulkRequest); - if (command.ticketIds() == null || command.ticketIds().isEmpty()) { - throw new ValidationException("No ticket IDs provided"); - } - - int updatedCount = ticketService.bulkUpdateTickets(server, command, staffEmail); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS); - panelActionAuditor.recordStaffAction(server, staffEmail, "Bulk updated " + updatedCount + " ticket(s)"); - return ResponseEntity.ok(PanelTicketProtoMapper.toBulkTicketUpdateResponse( - updatedCount, "Successfully updated " + updatedCount + " tickets")); - } - - @GetMapping("/{id}") - public ResponseEntity getTicket( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail != null && !staffEmail.isBlank()) { - subscriptionService.markTicketAsRead(server, id, staffEmail); - } - - return ResponseEntity.ok(PanelTicketProtoMapper.toTicketResponse(ticketService.getTicketById(server, id))); - } - - @PostMapping - public ResponseEntity createTicket( - @RequestBody gg.modl.proto.modl.v1.CreateTicketRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - TicketResponse ticket = ticketService.createTicket(server, PanelTicketProtoMapper.fromCreateTicketRequest(createRequest)); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, ticket.id()); - return ResponseEntity.status(HttpStatus.CREATED).body(PanelTicketProtoMapper.toTicketResponse(ticket)); - } - - @PatchMapping("/{id}") - public ResponseEntity updateTicket( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.UpdateTicketRequest updateRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - UpdateTicketRequest command = PanelTicketProtoMapper.fromUpdateTicketRequest(updateRequest); - TicketResponse ticket = ticketService.updateTicket(server, id, command, staffEmail); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, ticket.id()); - panelActionAuditor.recordStaffAction(server, staffEmail, "Updated ticket " + id); - return ResponseEntity.ok(PanelTicketProtoMapper.toTicketResponse(ticket)); - } - - @PostMapping("/{id}/notes") - public ResponseEntity addNote( - @PathVariable String id, - @RequestBody AddNoteRequest noteRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - gg.modl.backend.ticket.data.TicketNote note = - ticketReplyService.addNote(server, id, PanelTicketProtoMapper.fromAddNoteRequest(noteRequest)); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - return ResponseEntity.status(HttpStatus.CREATED).body(PanelTicketProtoMapper.toTicketNoteResponse(note)); - } - - @PostMapping("/{id}/replies") - public ResponseEntity addReply( - @PathVariable String id, - @RequestBody AddReplyRequest replyRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - gg.modl.backend.ticket.dto.request.AddReplyRequest command = - PanelTicketProtoMapper.fromAddReplyRequest(replyRequest); - TicketReply reply = ticketReplyService.addReply(server, id, command); - - if (command.staff() && staffEmail != null && !staffEmail.isBlank()) { - subscriptionService.ensureSubscription(server, id, staffEmail); - } - - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - panelActionAuditor.recordStaffAction(server, staffEmail, "Replied to ticket " + id); - return ResponseEntity.status(HttpStatus.CREATED).body(PanelTicketProtoMapper.toAddReplyResponse(reply)); - } - - @PostMapping("/{id}/tags") - public ResponseEntity addTag( - @PathVariable String id, - @RequestBody AddTagRequest tagRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - List tags = ticketReplyService.addTag(server, id, tagRequest.getTag()); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - return ResponseEntity.ok(PanelTicketProtoMapper.toTagsResponse(tags)); - } - - @DeleteMapping("/{id}/tags/{tag}") - public ResponseEntity removeTag( - @PathVariable String id, - @PathVariable String tag, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - List tags = ticketReplyService.removeTag(server, id, tag); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - return ResponseEntity.ok(PanelTicketProtoMapper.toTagsResponse(tags)); - } - - @GetMapping("/player/{uuid}") - public ResponseEntity getTicketsByPlayer( - @PathVariable String uuid, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(ticketSearchService.getTicketsByPlayer(server, uuid)); - } - - @GetMapping("/tag/{tag}") - public ResponseEntity getTicketsByTag( - @PathVariable String tag, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - return ResponseEntity.ok(ticketSearchService.getTicketsByTag(server, tag)); - } - - @PostMapping("/{id}/quick-response") - public ResponseEntity quickResponse( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.QuickResponseRequest quickRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - String staffUsername = staffEmail != null ? staffEmail.split("@")[0] : "System"; - - QuickResponseRequest command = PanelTicketProtoMapper.fromQuickResponseRequest(quickRequest); - QuickResponseResult result = ticketService.processQuickResponse(server, id, command, staffUsername); - - if (!result.success()) { - return ResponseEntity.badRequest().body(PanelTicketProtoMapper.toQuickResponseResult(result)); - } - - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - return ResponseEntity.ok(PanelTicketProtoMapper.toQuickResponseResult(result)); - } -} +package gg.modl.backend.ticket.controller; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.log.service.PanelActionAuditor; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.request.BulkTicketUpdateRequest; +import gg.modl.backend.ticket.dto.request.CreateTicketRequest; +import gg.modl.backend.ticket.dto.request.QuickResponseRequest; +import gg.modl.backend.ticket.dto.request.UpdateTicketRequest; +import gg.modl.backend.ticket.dto.response.PaginatedTicketsResponse; +import gg.modl.backend.ticket.dto.response.QuickResponseResult; +import gg.modl.backend.ticket.dto.response.TicketResponse; +import gg.modl.backend.ticket.service.TicketReplyService; +import gg.modl.backend.ticket.service.TicketSearchService; +import gg.modl.backend.ticket.service.TicketService; +import gg.modl.backend.ticket.service.TicketSubscriptionService; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.AddNoteRequest; +import gg.modl.proto.modl.v1.AddReplyRequest; +import gg.modl.proto.modl.v1.AddTagRequest; +import gg.modl.proto.modl.v1.AddTicketReplyResponse; +import gg.modl.proto.modl.v1.BulkTicketUpdateResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.TicketCountsResponse; +import gg.modl.proto.modl.v1.TicketNote; +import gg.modl.proto.modl.v1.TicketTagsResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_TICKETS) +@RequiredArgsConstructor +@Validated +public class PanelTicketController { + private final TicketService ticketService; + private final TicketSearchService ticketSearchService; + private final TicketReplyService ticketReplyService; + private final TicketSubscriptionService subscriptionService; + private final RealtimeEventPublisher realtimeEventPublisher; + private final PanelActionAuditor panelActionAuditor; + + @GetMapping + public ResponseEntity searchTickets( + @RequestParam(defaultValue = "1") @Min(RequestValidationLimits.PAGINATION_PAGE_MIN) int page, + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + @RequestParam(required = false) String search, + @RequestParam(required = false) String status, + @RequestParam(required = false) List type, + @RequestParam(required = false) String author, + @RequestParam(required = false) List labels, + @RequestParam(required = false) List assignee, + @RequestParam(defaultValue = "newest") String sort, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + PaginatedTicketsResponse response = ticketSearchService.searchTickets( + server, page, limit, search, status, type, author, labels, assignee, sort); + return ResponseEntity.ok(PanelTicketProtoMapper.toPaginatedTicketsResponse(response)); + } + + @GetMapping("/counts") + public ResponseEntity getTicketCounts( + @RequestParam(required = false) String search, + @RequestParam(required = false) List type, + @RequestParam(required = false) String author, + @RequestParam(required = false) List labels, + @RequestParam(required = false) List assignee, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + Map counts = ticketSearchService.getTicketCounts(server, search, type, author, labels, assignee); + return ResponseEntity.ok(PanelTicketProtoMapper.toTicketCountsResponse(counts)); + } + + @PostMapping("/bulk") + public ResponseEntity bulkUpdateTickets( + @RequestBody gg.modl.proto.modl.v1.BulkTicketUpdateRequest bulkRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + BulkTicketUpdateRequest command = PanelTicketProtoMapper.fromBulkTicketUpdateRequest(bulkRequest); + if (command.ticketIds() == null || command.ticketIds().isEmpty()) { + throw new ValidationException("No ticket IDs provided"); + } + + int updatedCount = ticketService.bulkUpdateTickets(server, command, staffEmail); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS); + panelActionAuditor.recordStaffAction(server, staffEmail, "Bulk updated " + updatedCount + " ticket(s)"); + return ResponseEntity.ok(PanelTicketProtoMapper.toBulkTicketUpdateResponse( + updatedCount, "Successfully updated " + updatedCount + " tickets")); + } + + @GetMapping("/{id}") + public ResponseEntity getTicket( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail != null && !staffEmail.isBlank()) { + subscriptionService.markTicketAsRead(server, id, staffEmail); + } + + return ResponseEntity.ok(PanelTicketProtoMapper.toTicketResponse(ticketService.getTicketById(server, id))); + } + + @PostMapping + public ResponseEntity createTicket( + @RequestBody gg.modl.proto.modl.v1.CreateTicketRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + TicketResponse ticket = ticketService.createTicket(server, PanelTicketProtoMapper.fromCreateTicketRequest(createRequest)); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, ticket.id()); + return ResponseEntity.status(HttpStatus.CREATED).body(PanelTicketProtoMapper.toTicketResponse(ticket)); + } + + @PatchMapping("/{id}") + public ResponseEntity updateTicket( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.UpdateTicketRequest updateRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + UpdateTicketRequest command = PanelTicketProtoMapper.fromUpdateTicketRequest(updateRequest); + TicketResponse ticket = ticketService.updateTicket(server, id, command, staffEmail); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, ticket.id()); + panelActionAuditor.recordStaffAction(server, staffEmail, "Updated ticket " + id); + return ResponseEntity.ok(PanelTicketProtoMapper.toTicketResponse(ticket)); + } + + @PostMapping("/{id}/notes") + public ResponseEntity addNote( + @PathVariable String id, + @RequestBody AddNoteRequest noteRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + gg.modl.backend.ticket.data.TicketNote note = + ticketReplyService.addNote(server, id, PanelTicketProtoMapper.fromAddNoteRequest(noteRequest)); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + return ResponseEntity.status(HttpStatus.CREATED).body(PanelTicketProtoMapper.toTicketNoteResponse(note)); + } + + @PostMapping("/{id}/replies") + public ResponseEntity addReply( + @PathVariable String id, + @RequestBody AddReplyRequest replyRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + gg.modl.backend.ticket.dto.request.AddReplyRequest command = + PanelTicketProtoMapper.fromAddReplyRequest(replyRequest); + TicketReply reply = ticketReplyService.addReply(server, id, command); + + if (command.staff() && staffEmail != null && !staffEmail.isBlank()) { + subscriptionService.ensureSubscription(server, id, staffEmail); + } + + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + panelActionAuditor.recordStaffAction(server, staffEmail, "Replied to ticket " + id); + return ResponseEntity.status(HttpStatus.CREATED).body(PanelTicketProtoMapper.toAddReplyResponse(reply)); + } + + @PostMapping("/{id}/tags") + public ResponseEntity addTag( + @PathVariable String id, + @RequestBody AddTagRequest tagRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + List tags = ticketReplyService.addTag(server, id, tagRequest.getTag()); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + return ResponseEntity.ok(PanelTicketProtoMapper.toTagsResponse(tags)); + } + + @DeleteMapping("/{id}/tags/{tag}") + public ResponseEntity removeTag( + @PathVariable String id, + @PathVariable String tag, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + List tags = ticketReplyService.removeTag(server, id, tag); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + return ResponseEntity.ok(PanelTicketProtoMapper.toTagsResponse(tags)); + } + + @GetMapping("/player/{uuid}") + public ResponseEntity getTicketsByPlayer( + @PathVariable String uuid, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(ticketSearchService.getTicketsByPlayer(server, uuid)); + } + + @GetMapping("/tag/{tag}") + public ResponseEntity getTicketsByTag( + @PathVariable String tag, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + return ResponseEntity.ok(ticketSearchService.getTicketsByTag(server, tag)); + } + + @PostMapping("/{id}/quick-response") + public ResponseEntity quickResponse( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.QuickResponseRequest quickRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + String staffUsername = staffEmail != null ? staffEmail.split("@")[0] : "System"; + + QuickResponseRequest command = PanelTicketProtoMapper.fromQuickResponseRequest(quickRequest); + QuickResponseResult result = ticketService.processQuickResponse(server, id, command, staffUsername); + + if (!result.success()) { + return ResponseEntity.badRequest().body(PanelTicketProtoMapper.toQuickResponseResult(result)); + } + + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + return ResponseEntity.ok(PanelTicketProtoMapper.toQuickResponseResult(result)); + } +} diff --git a/src/main/java/gg/modl/backend/ticket/controller/PublicTicketController.java b/src/main/java/gg/modl/backend/ticket/controller/PublicTicketController.java index ec7d9be..467400c 100644 --- a/src/main/java/gg/modl/backend/ticket/controller/PublicTicketController.java +++ b/src/main/java/gg/modl/backend/ticket/controller/PublicTicketController.java @@ -1,201 +1,201 @@ -package gg.modl.backend.ticket.controller; - -import gg.modl.backend.infrastructure.exception.ForbiddenException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.response.TicketResponse; -import gg.modl.backend.ticket.service.PublicRecordAccessService; -import gg.modl.backend.ticket.service.PublicRecordAccessService.Access; -import gg.modl.backend.ticket.service.PublicRecordAccessService.AccessResult; -import gg.modl.backend.ticket.service.PublicRecordVerificationService; -import gg.modl.backend.ticket.service.TicketReplyService; -import gg.modl.backend.ticket.service.TicketService; -import gg.modl.proto.modl.v1.AddReplyRequest; -import gg.modl.proto.modl.v1.AddTicketReplyResponse; -import gg.modl.proto.modl.v1.PanelResource; -import jakarta.servlet.http.HttpServletRequest; -import java.util.List; -import java.util.Set; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -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.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; - -@RestController -@RequestMapping(RESTMappingV1.PUBLIC_TICKETS) -@RequiredArgsConstructor -public class PublicTicketController { - private final TicketService ticketService; - private final TicketReplyService ticketReplyService; - private final PublicRecordAccessService recordAccessService; - private final PublicRecordVerificationService recordVerificationService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @PostMapping - public ResponseEntity createTicket( - @RequestBody gg.modl.proto.modl.v1.CreateTicketRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - TicketResponse ticket = ticketService.createTicket(server, PanelTicketProtoMapper.fromCreateTicketRequest(createRequest)); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, ticket.id()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(PublicTicketProtoMapper.toCreateTicketResponse(ticket, "Ticket created successfully")); - } - - @PostMapping("/unfinished") - public ResponseEntity createUnfinishedTicket( - @RequestBody gg.modl.proto.modl.v1.CreateTicketRequest createRequest, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - TicketResponse ticket = ticketService.createUnfinishedTicket(server, PanelTicketProtoMapper.fromCreateTicketRequest(createRequest)); - return ResponseEntity.status(HttpStatus.CREATED) - .body(PublicTicketProtoMapper.toCreateTicketResponse(ticket, "Ticket created successfully (Unfinished)")); - } - - @GetMapping("/{id}") - public ResponseEntity getTicket( - @PathVariable String id, - @RequestParam(value = "token", required = false) String ticketToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, ticket, ticketToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); - } - - TicketResponse ticketResponse = ticketService.toResponse(server, ticket); - Set formFieldAllowlist = ticketService.getPublicFormFieldIds(server, ticket); - return ResponseEntity.ok(PublicTicketProtoMapper.toPublicTicketResponse(ticketResponse, ticket, formFieldAllowlist)); - } - - @GetMapping("/{id}/status") - public ResponseEntity getTicketStatus( - @PathVariable String id, - @RequestParam(value = "token", required = false) String ticketToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, ticket, ticketToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - throw new ForbiddenException("Email verification required"); - } - - TicketResponse ticketResp = ticketService.toResponse(server, ticket); - return ResponseEntity.ok(PublicTicketProtoMapper.toStatusResponse(ticketResp)); - } - - @PostMapping("/{id}/replies") - public ResponseEntity addReply( - @PathVariable String id, - @RequestBody AddReplyRequest replyRequest, - @RequestParam(value = "token", required = false) String ticketToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorize(server, ticket, ticketToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - throw new ForbiddenException("Email verification required"); - } - - List attachments = PublicTicketProtoMapper.attachmentsFromReply(replyRequest); - TicketReply reply = ticketReplyService.addPublicReply(server, id, replyRequest.getContent(), attachments); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - return ResponseEntity.status(HttpStatus.CREATED) - .body(AddTicketReplyResponse.newBuilder() - .setSuccess(true) - .setMessage("Reply added successfully") - .setReply(PublicTicketProtoMapper.toPublicReply(reply)) - .build()); - } - - @PostMapping("/{id}/submit") - public ResponseEntity submitTicketForm( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.SubmitTicketFormRequest submitRequest, - @RequestParam(value = "token", required = false) String ticketToken, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); - AccessResult access = recordAccessService.authorizeSubmission(server, ticket, ticketToken); - if (access.access() == Access.NOT_FOUND) { - return ResponseEntity.notFound().build(); - } - if (access.access() == Access.TOKEN_REQUIRED) { - throw new ForbiddenException("Email verification required"); - } - - if (ticket.isLocked() || (ticket.getStatus() != null && ticket.getStatus().isTerminal())) { - throw new ForbiddenException("Ticket is closed and cannot be resubmitted"); - } - - TicketResponse ticketResp = ticketService.submitTicketForm( - server, id, PublicTicketProtoMapper.fromSubmitTicketFormRequest(submitRequest), access.tokenVerified()); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); - return ResponseEntity.ok(PublicTicketProtoMapper.toSubmitResponse(ticketResp)); - } - - @PostMapping("/{id}/request-verification") - public ResponseEntity requestVerification( - @PathVariable String id, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - Ticket ticket = ticketService.getTicketRaw(server, id).filter(t -> !t.isHidden()).orElse(null); - if (ticket == null) { - return ResponseEntity.notFound().build(); - } - - String emailHint = recordVerificationService.sendVerificationCode(server, ticket); - return ResponseEntity.ok(PublicVerificationProtoMapper.toRequestVerificationResponse(emailHint)); - } - - @PostMapping("/{id}/verify") - public ResponseEntity verifyCode( - @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.VerifyTicketCodeRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - if (ticketService.getTicketRaw(server, id).filter(t -> !t.isHidden()).isEmpty()) { - return ResponseEntity.notFound().build(); - } - - String token = recordVerificationService.verifyCode(server, id, body.getCode()); - return ResponseEntity.ok(PublicVerificationProtoMapper.toVerifyResponse(token)); - } -} +package gg.modl.backend.ticket.controller; + +import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.response.TicketResponse; +import gg.modl.backend.ticket.service.PublicRecordAccessService; +import gg.modl.backend.ticket.service.PublicRecordAccessService.Access; +import gg.modl.backend.ticket.service.PublicRecordAccessService.AccessResult; +import gg.modl.backend.ticket.service.PublicRecordVerificationService; +import gg.modl.backend.ticket.service.TicketReplyService; +import gg.modl.backend.ticket.service.TicketService; +import gg.modl.proto.modl.v1.AddReplyRequest; +import gg.modl.proto.modl.v1.AddTicketReplyResponse; +import gg.modl.proto.modl.v1.PanelResource; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +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.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; + +@RestController +@RequestMapping(RESTMappingV1.PUBLIC_TICKETS) +@RequiredArgsConstructor +public class PublicTicketController { + private final TicketService ticketService; + private final TicketReplyService ticketReplyService; + private final PublicRecordAccessService recordAccessService; + private final PublicRecordVerificationService recordVerificationService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @PostMapping + public ResponseEntity createTicket( + @RequestBody gg.modl.proto.modl.v1.CreateTicketRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + TicketResponse ticket = ticketService.createTicket(server, PanelTicketProtoMapper.fromCreateTicketRequest(createRequest)); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, ticket.id()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(PublicTicketProtoMapper.toCreateTicketResponse(ticket, "Ticket created successfully")); + } + + @PostMapping("/unfinished") + public ResponseEntity createUnfinishedTicket( + @RequestBody gg.modl.proto.modl.v1.CreateTicketRequest createRequest, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + TicketResponse ticket = ticketService.createUnfinishedTicket(server, PanelTicketProtoMapper.fromCreateTicketRequest(createRequest)); + return ResponseEntity.status(HttpStatus.CREATED) + .body(PublicTicketProtoMapper.toCreateTicketResponse(ticket, "Ticket created successfully (Unfinished)")); + } + + @GetMapping("/{id}") + public ResponseEntity getTicket( + @PathVariable String id, + @RequestParam(value = "token", required = false) String ticketToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, ticket, ticketToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); + } + + TicketResponse ticketResponse = ticketService.toResponse(server, ticket); + Set formFieldAllowlist = ticketService.getPublicFormFieldIds(server, ticket); + return ResponseEntity.ok(PublicTicketProtoMapper.toPublicTicketResponse(ticketResponse, ticket, formFieldAllowlist)); + } + + @GetMapping("/{id}/status") + public ResponseEntity getTicketStatus( + @PathVariable String id, + @RequestParam(value = "token", required = false) String ticketToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, ticket, ticketToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + throw new ForbiddenException("Email verification required"); + } + + TicketResponse ticketResp = ticketService.toResponse(server, ticket); + return ResponseEntity.ok(PublicTicketProtoMapper.toStatusResponse(ticketResp)); + } + + @PostMapping("/{id}/replies") + public ResponseEntity addReply( + @PathVariable String id, + @RequestBody AddReplyRequest replyRequest, + @RequestParam(value = "token", required = false) String ticketToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorize(server, ticket, ticketToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + throw new ForbiddenException("Email verification required"); + } + + List attachments = PublicTicketProtoMapper.attachmentsFromReply(replyRequest); + TicketReply reply = ticketReplyService.addPublicReply(server, id, replyRequest.getContent(), attachments); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + return ResponseEntity.status(HttpStatus.CREATED) + .body(AddTicketReplyResponse.newBuilder() + .setSuccess(true) + .setMessage("Reply added successfully") + .setReply(PublicTicketProtoMapper.toPublicReply(reply)) + .build()); + } + + @PostMapping("/{id}/submit") + public ResponseEntity submitTicketForm( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.SubmitTicketFormRequest submitRequest, + @RequestParam(value = "token", required = false) String ticketToken, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket ticket = ticketService.getTicketRaw(server, id).orElse(null); + AccessResult access = recordAccessService.authorizeSubmission(server, ticket, ticketToken); + if (access.access() == Access.NOT_FOUND) { + return ResponseEntity.notFound().build(); + } + if (access.access() == Access.TOKEN_REQUIRED) { + throw new ForbiddenException("Email verification required"); + } + + if (ticket.isLocked() || (ticket.getStatus() != null && ticket.getStatus().isTerminal())) { + throw new ForbiddenException("Ticket is closed and cannot be resubmitted"); + } + + TicketResponse ticketResp = ticketService.submitTicketForm( + server, id, PublicTicketProtoMapper.fromSubmitTicketFormRequest(submitRequest), access.tokenVerified()); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_TICKETS, id); + return ResponseEntity.ok(PublicTicketProtoMapper.toSubmitResponse(ticketResp)); + } + + @PostMapping("/{id}/request-verification") + public ResponseEntity requestVerification( + @PathVariable String id, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + Ticket ticket = ticketService.getTicketRaw(server, id).filter(t -> !t.isHidden()).orElse(null); + if (ticket == null) { + return ResponseEntity.notFound().build(); + } + + String emailHint = recordVerificationService.sendVerificationCode(server, ticket); + return ResponseEntity.ok(PublicVerificationProtoMapper.toRequestVerificationResponse(emailHint)); + } + + @PostMapping("/{id}/verify") + public ResponseEntity verifyCode( + @PathVariable String id, + @RequestBody gg.modl.proto.modl.v1.VerifyTicketCodeRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + if (ticketService.getTicketRaw(server, id).filter(t -> !t.isHidden()).isEmpty()) { + return ResponseEntity.notFound().build(); + } + + String token = recordVerificationService.verifyCode(server, id, body.getCode()); + return ResponseEntity.ok(PublicVerificationProtoMapper.toVerifyResponse(token)); + } +} diff --git a/src/main/java/gg/modl/backend/ticket/controller/TicketSubscriptionController.java b/src/main/java/gg/modl/backend/ticket/controller/TicketSubscriptionController.java index a0d16cf..171636d 100644 --- a/src/main/java/gg/modl/backend/ticket/controller/TicketSubscriptionController.java +++ b/src/main/java/gg/modl/backend/ticket/controller/TicketSubscriptionController.java @@ -1,139 +1,139 @@ -package gg.modl.backend.ticket.controller; - -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.infrastructure.exception.UnauthorizedException; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RequestUtil; -import gg.modl.backend.realtime.publish.RealtimeEventPublisher; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.dto.response.SubscriptionUpdateResponse; -import gg.modl.backend.ticket.dto.response.TicketSubscriptionResponse; -import gg.modl.backend.ticket.service.TicketSubscriptionService; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import gg.modl.proto.modl.v1.DeleteTicketSubscriptionResponse; -import gg.modl.proto.modl.v1.MarkSubscriptionUpdateReadResponse; -import gg.modl.proto.modl.v1.MarkTicketSubscriptionReadResponse; -import gg.modl.proto.modl.v1.PanelResource; -import gg.modl.proto.modl.v1.SubscriptionUpdatesResponse; -import gg.modl.proto.modl.v1.TicketSubscriptionsResponse; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.validation.annotation.Validated; -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; - -@RestController -@RequestMapping(RESTMappingV1.PANEL_TICKET_SUBSCRIPTIONS) -@RequiredArgsConstructor -@Validated -public class TicketSubscriptionController { - private final TicketSubscriptionService subscriptionService; - private final RealtimeEventPublisher realtimeEventPublisher; - - @GetMapping - public ResponseEntity getSubscriptions(HttpServletRequest request) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail == null || staffEmail.isBlank()) { - return ResponseEntity.status(401).build(); - } - - List subscriptions = subscriptionService.getSubscriptions(server, staffEmail); - return ResponseEntity.ok(PanelTicketProtoMapper.toTicketSubscriptionsResponse(subscriptions)); - } - - @DeleteMapping("/{ticketId}") - public ResponseEntity unsubscribe( - @PathVariable String ticketId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail == null || staffEmail.isBlank()) { - throw new UnauthorizedException("Not authenticated"); - } - - if (!subscriptionService.unsubscribe(server, staffEmail, ticketId)) { - throw new ResourceNotFoundException("Subscription not found"); - } - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_NOTIFICATIONS); - return ResponseEntity.ok(PanelTicketProtoMapper.toDeleteSubscriptionResponse("Successfully unsubscribed from ticket")); - } - - @GetMapping("/updates") - public ResponseEntity getUpdates( - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail == null || staffEmail.isBlank()) { - return ResponseEntity.status(401).build(); - } - - List updates = subscriptionService.getUpdates(server, staffEmail, limit); - return ResponseEntity.ok(PanelTicketProtoMapper.toSubscriptionUpdatesResponse(updates)); - } - - @PostMapping("/updates/{updateId}/read") - public ResponseEntity markAsRead( - @PathVariable String updateId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail == null || staffEmail.isBlank()) { - throw new UnauthorizedException("Not authenticated"); - } - - boolean result = subscriptionService.markAsRead(server, staffEmail, updateId); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_NOTIFICATIONS); - return ResponseEntity.ok(PanelTicketProtoMapper.toMarkUpdateReadResponse("Update marked as read", result)); - } - - @PostMapping("/tickets/{ticketId}/read") - public ResponseEntity markTicketAsRead( - @PathVariable String ticketId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail == null || staffEmail.isBlank()) { - throw new UnauthorizedException("Not authenticated"); - } - - subscriptionService.markTicketAsRead(server, ticketId, staffEmail); - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_NOTIFICATIONS, ticketId); - return ResponseEntity.ok(PanelTicketProtoMapper.toMarkTicketReadResponse("All updates for ticket marked as read")); - } - - @GetMapping("/assigned-updates") - public ResponseEntity getAssignedUpdates( - @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String staffEmail = RequestUtil.getSessionEmail(request); - - if (staffEmail == null || staffEmail.isBlank()) { - return ResponseEntity.status(401).build(); - } - - List updates = subscriptionService.getAssignedTicketUpdates(server, staffEmail, limit); - return ResponseEntity.ok(PanelTicketProtoMapper.toSubscriptionUpdatesResponse(updates)); - } -} +package gg.modl.backend.ticket.controller; + +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.exception.UnauthorizedException; +import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.rest.RequestUtil; +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.dto.response.SubscriptionUpdateResponse; +import gg.modl.backend.ticket.dto.response.TicketSubscriptionResponse; +import gg.modl.backend.ticket.service.TicketSubscriptionService; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.DeleteTicketSubscriptionResponse; +import gg.modl.proto.modl.v1.MarkSubscriptionUpdateReadResponse; +import gg.modl.proto.modl.v1.MarkTicketSubscriptionReadResponse; +import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.SubscriptionUpdatesResponse; +import gg.modl.proto.modl.v1.TicketSubscriptionsResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +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; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_TICKET_SUBSCRIPTIONS) +@RequiredArgsConstructor +@Validated +public class TicketSubscriptionController { + private final TicketSubscriptionService subscriptionService; + private final RealtimeEventPublisher realtimeEventPublisher; + + @GetMapping + public ResponseEntity getSubscriptions(HttpServletRequest request) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail == null || staffEmail.isBlank()) { + return ResponseEntity.status(401).build(); + } + + List subscriptions = subscriptionService.getSubscriptions(server, staffEmail); + return ResponseEntity.ok(PanelTicketProtoMapper.toTicketSubscriptionsResponse(subscriptions)); + } + + @DeleteMapping("/{ticketId}") + public ResponseEntity unsubscribe( + @PathVariable String ticketId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail == null || staffEmail.isBlank()) { + throw new UnauthorizedException("Not authenticated"); + } + + if (!subscriptionService.unsubscribe(server, staffEmail, ticketId)) { + throw new ResourceNotFoundException("Subscription not found"); + } + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_NOTIFICATIONS); + return ResponseEntity.ok(PanelTicketProtoMapper.toDeleteSubscriptionResponse("Successfully unsubscribed from ticket")); + } + + @GetMapping("/updates") + public ResponseEntity getUpdates( + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail == null || staffEmail.isBlank()) { + return ResponseEntity.status(401).build(); + } + + List updates = subscriptionService.getUpdates(server, staffEmail, limit); + return ResponseEntity.ok(PanelTicketProtoMapper.toSubscriptionUpdatesResponse(updates)); + } + + @PostMapping("/updates/{updateId}/read") + public ResponseEntity markAsRead( + @PathVariable String updateId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail == null || staffEmail.isBlank()) { + throw new UnauthorizedException("Not authenticated"); + } + + boolean result = subscriptionService.markAsRead(server, staffEmail, updateId); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_NOTIFICATIONS); + return ResponseEntity.ok(PanelTicketProtoMapper.toMarkUpdateReadResponse("Update marked as read", result)); + } + + @PostMapping("/tickets/{ticketId}/read") + public ResponseEntity markTicketAsRead( + @PathVariable String ticketId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail == null || staffEmail.isBlank()) { + throw new UnauthorizedException("Not authenticated"); + } + + subscriptionService.markTicketAsRead(server, ticketId, staffEmail); + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_NOTIFICATIONS, ticketId); + return ResponseEntity.ok(PanelTicketProtoMapper.toMarkTicketReadResponse("All updates for ticket marked as read")); + } + + @GetMapping("/assigned-updates") + public ResponseEntity getAssignedUpdates( + @RequestParam(defaultValue = "10") @Min(RequestValidationLimits.PAGINATION_LIMIT_MIN) @Max(RequestValidationLimits.PAGINATION_LIMIT_MAX) int limit, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String staffEmail = RequestUtil.getSessionEmail(request); + + if (staffEmail == null || staffEmail.isBlank()) { + return ResponseEntity.status(401).build(); + } + + List updates = subscriptionService.getAssignedTicketUpdates(server, staffEmail, limit); + return ResponseEntity.ok(PanelTicketProtoMapper.toSubscriptionUpdatesResponse(updates)); + } +} diff --git a/src/main/java/gg/modl/backend/ticket/data/TicketNote.java b/src/main/java/gg/modl/backend/ticket/data/TicketNote.java index 500bd50..e868fd1 100644 --- a/src/main/java/gg/modl/backend/ticket/data/TicketNote.java +++ b/src/main/java/gg/modl/backend/ticket/data/TicketNote.java @@ -1,18 +1,18 @@ -package gg.modl.backend.ticket.data; - -import java.util.Date; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class TicketNote { - private String text; - private String issuerName; - private String issuerAvatar; - private Date date; -} +package gg.modl.backend.ticket.data; + +import java.util.Date; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TicketNote { + private String text; + private String issuerName; + private String issuerAvatar; + private Date date; +} diff --git a/src/main/java/gg/modl/backend/ticket/data/TicketPriority.java b/src/main/java/gg/modl/backend/ticket/data/TicketPriority.java index 8217d7e..d65228f 100644 --- a/src/main/java/gg/modl/backend/ticket/data/TicketPriority.java +++ b/src/main/java/gg/modl/backend/ticket/data/TicketPriority.java @@ -1,50 +1,50 @@ -package gg.modl.backend.ticket.data; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; -import gg.modl.backend.infrastructure.util.CanonicalAliasIndex; - -public enum TicketPriority { - LOW("low", "Low"), - NORMAL("normal", "Normal"), - HIGH("high", "High"); - - private final String id; - private final String displayName; - private static final CanonicalAliasIndex INDEX = CanonicalAliasIndex - .of("ticket priority", values(), TicketPriority::getId) - .alias(LOW, "minor") - .alias(NORMAL, "medium") - .alias(NORMAL, "default") - .alias(NORMAL, "standard") - .alias(HIGH, "urgent") - .alias(HIGH, "critical") - .alias(HIGH, "highest"); - - TicketPriority(String id, String displayName) { - this.id = id; - this.displayName = displayName; - } - - public static TicketPriority resolveOrDefault(String priority) { - return priority == null || priority.isBlank() ? NORMAL : fromCanonicalId(priority); - } - - public static TicketPriority fromCanonicalId(String value) { - return INDEX.resolve(value); - } - - @JsonCreator - public static TicketPriority fromValue(String value) { - return fromCanonicalId(value); - } - - @JsonValue - public String getId() { - return id; - } - - public String getDisplayName() { - return displayName; - } -} +package gg.modl.backend.ticket.data; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import gg.modl.backend.infrastructure.util.CanonicalAliasIndex; + +public enum TicketPriority { + LOW("low", "Low"), + NORMAL("normal", "Normal"), + HIGH("high", "High"); + + private final String id; + private final String displayName; + private static final CanonicalAliasIndex INDEX = CanonicalAliasIndex + .of("ticket priority", values(), TicketPriority::getId) + .alias(LOW, "minor") + .alias(NORMAL, "medium") + .alias(NORMAL, "default") + .alias(NORMAL, "standard") + .alias(HIGH, "urgent") + .alias(HIGH, "critical") + .alias(HIGH, "highest"); + + TicketPriority(String id, String displayName) { + this.id = id; + this.displayName = displayName; + } + + public static TicketPriority resolveOrDefault(String priority) { + return priority == null || priority.isBlank() ? NORMAL : fromCanonicalId(priority); + } + + public static TicketPriority fromCanonicalId(String value) { + return INDEX.resolve(value); + } + + @JsonCreator + public static TicketPriority fromValue(String value) { + return fromCanonicalId(value); + } + + @JsonValue + public String getId() { + return id; + } + + public String getDisplayName() { + return displayName; + } +} diff --git a/src/main/java/gg/modl/backend/ticket/data/TicketReply.java b/src/main/java/gg/modl/backend/ticket/data/TicketReply.java index ae25390..d2c8135 100644 --- a/src/main/java/gg/modl/backend/ticket/data/TicketReply.java +++ b/src/main/java/gg/modl/backend/ticket/data/TicketReply.java @@ -1,27 +1,27 @@ -package gg.modl.backend.ticket.data; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder(toBuilder = true) -@NoArgsConstructor -@AllArgsConstructor -public class TicketReply { - private String id; - private String name; - private String avatar; - private String content; - private String type; - private Date created; - private boolean staff; - private String action; - @Builder.Default - private List attachments = new ArrayList<>(); - private String creatorIdentifier; -} +package gg.modl.backend.ticket.data; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder(toBuilder = true) +@NoArgsConstructor +@AllArgsConstructor +public class TicketReply { + private String id; + private String name; + private String avatar; + private String content; + private String type; + private Date created; + private boolean staff; + private String action; + @Builder.Default + private List attachments = new ArrayList<>(); + private String creatorIdentifier; +} diff --git a/src/main/java/gg/modl/backend/ticket/dto/response/PaginatedTicketsResponse.java b/src/main/java/gg/modl/backend/ticket/dto/response/PaginatedTicketsResponse.java index c45f333..84c1bae 100644 --- a/src/main/java/gg/modl/backend/ticket/dto/response/PaginatedTicketsResponse.java +++ b/src/main/java/gg/modl/backend/ticket/dto/response/PaginatedTicketsResponse.java @@ -1,26 +1,26 @@ -package gg.modl.backend.ticket.dto.response; - -import java.util.List; - -public record PaginatedTicketsResponse( - List tickets, - PaginationInfo pagination, - FiltersInfo filters -) { - public record PaginationInfo( - int current, - int total, - int limit, - long totalTickets, - boolean hasNext, - boolean hasPrev - ) { - } - - public record FiltersInfo( - String search, - String status, - List types - ) { - } -} +package gg.modl.backend.ticket.dto.response; + +import java.util.List; + +public record PaginatedTicketsResponse( + List tickets, + PaginationInfo pagination, + FiltersInfo filters +) { + public record PaginationInfo( + int current, + int total, + int limit, + long totalTickets, + boolean hasNext, + boolean hasPrev + ) { + } + + public record FiltersInfo( + String search, + String status, + List types + ) { + } +} diff --git a/src/main/java/gg/modl/backend/ticket/dto/response/QuickResponseResult.java b/src/main/java/gg/modl/backend/ticket/dto/response/QuickResponseResult.java index 26808f2..631f0e2 100644 --- a/src/main/java/gg/modl/backend/ticket/dto/response/QuickResponseResult.java +++ b/src/main/java/gg/modl/backend/ticket/dto/response/QuickResponseResult.java @@ -1,11 +1,11 @@ -package gg.modl.backend.ticket.dto.response; - -public record QuickResponseResult( - boolean success, - String message, - String ticketId, - String actionName, - boolean ticketClosed, - boolean punishmentIssued, - String appealOutcome -) {} +package gg.modl.backend.ticket.dto.response; + +public record QuickResponseResult( + boolean success, + String message, + String ticketId, + String actionName, + boolean ticketClosed, + boolean punishmentIssued, + String appealOutcome +) {} diff --git a/src/main/java/gg/modl/backend/ticket/dto/response/SubscriptionUpdateResponse.java b/src/main/java/gg/modl/backend/ticket/dto/response/SubscriptionUpdateResponse.java index e642bf6..7c54ebb 100644 --- a/src/main/java/gg/modl/backend/ticket/dto/response/SubscriptionUpdateResponse.java +++ b/src/main/java/gg/modl/backend/ticket/dto/response/SubscriptionUpdateResponse.java @@ -1,16 +1,16 @@ -package gg.modl.backend.ticket.dto.response; - -import java.util.Date; - -public record SubscriptionUpdateResponse( - String id, - String ticketId, - String ticketTitle, - String replyContent, - String replyBy, - Date replyAt, - boolean isStaffReply, - boolean isRead, - Integer additionalCount -) { -} +package gg.modl.backend.ticket.dto.response; + +import java.util.Date; + +public record SubscriptionUpdateResponse( + String id, + String ticketId, + String ticketTitle, + String replyContent, + String replyBy, + Date replyAt, + boolean isStaffReply, + boolean isRead, + Integer additionalCount +) { +} diff --git a/src/main/java/gg/modl/backend/ticket/dto/response/TicketListItemResponse.java b/src/main/java/gg/modl/backend/ticket/dto/response/TicketListItemResponse.java index a11a83b..7658a54 100644 --- a/src/main/java/gg/modl/backend/ticket/dto/response/TicketListItemResponse.java +++ b/src/main/java/gg/modl/backend/ticket/dto/response/TicketListItemResponse.java @@ -1,23 +1,23 @@ -package gg.modl.backend.ticket.dto.response; - -import gg.modl.backend.ticket.data.TicketReply; -import java.util.Date; -import java.util.List; - -public record TicketListItemResponse( - String id, - String subject, - String status, - String reportedBy, - String reportedByName, - Date date, - String category, - boolean locked, - String type, - TicketReply lastReply, - int replyCount, - List tags, - List assignedTo, - boolean hidden -) { -} +package gg.modl.backend.ticket.dto.response; + +import gg.modl.backend.ticket.data.TicketReply; +import java.util.Date; +import java.util.List; + +public record TicketListItemResponse( + String id, + String subject, + String status, + String reportedBy, + String reportedByName, + Date date, + String category, + boolean locked, + String type, + TicketReply lastReply, + int replyCount, + List tags, + List assignedTo, + boolean hidden +) { +} diff --git a/src/main/java/gg/modl/backend/ticket/dto/response/TicketSubscriptionResponse.java b/src/main/java/gg/modl/backend/ticket/dto/response/TicketSubscriptionResponse.java index f1217aa..23f5346 100644 --- a/src/main/java/gg/modl/backend/ticket/dto/response/TicketSubscriptionResponse.java +++ b/src/main/java/gg/modl/backend/ticket/dto/response/TicketSubscriptionResponse.java @@ -1,10 +1,10 @@ -package gg.modl.backend.ticket.dto.response; - -import java.util.Date; - -public record TicketSubscriptionResponse( - String ticketId, - String ticketTitle, - Date subscribedAt -) { -} +package gg.modl.backend.ticket.dto.response; + +import java.util.Date; + +public record TicketSubscriptionResponse( + String ticketId, + String ticketTitle, + Date subscribedAt +) { +} diff --git a/src/main/java/gg/modl/backend/ticket/service/TicketEmailVerificationService.java b/src/main/java/gg/modl/backend/ticket/service/TicketEmailVerificationService.java index 93796c1..1794c0d 100644 --- a/src/main/java/gg/modl/backend/ticket/service/TicketEmailVerificationService.java +++ b/src/main/java/gg/modl/backend/ticket/service/TicketEmailVerificationService.java @@ -1,131 +1,131 @@ -package gg.modl.backend.ticket.service; - -import gg.modl.backend.database.mongo.repository.TicketVerificationMongoRepository; -import gg.modl.backend.email.EmailAddressUtil; -import gg.modl.backend.email.EmailHTMLTemplate; -import gg.modl.backend.email.EmailService; -import gg.modl.backend.infrastructure.onetimecode.OneTimeCodeCodec; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketVerification; -import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import gg.modl.backend.infrastructure.exception.ValidationException; -import java.util.Date; -import java.util.UUID; -import gg.modl.backend.ticket.config.TicketEmailVerificationConfiguration; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class TicketEmailVerificationService { - private final TicketVerificationMongoRepository ticketVerificationRepository; - private final EmailService emailService; - private final TicketEmailVerificationConfiguration verificationConfig; - private final OneTimeCodeCodec oneTimeCodeCodec; - private static final int CODE_LENGTH = 6; - private static final String CREATOR_EMAIL_KEY = "creatorEmail"; - private static final String CONTACT_EMAIL_KEY = "contactEmail"; - - public String sendVerificationCode(Server server, Ticket ticket) { - String email = getCreatorEmail(ticket); - if (email == null || email.isBlank()) { - throw new ValidationException("No valid email associated with this ticket"); - } - - String code = oneTimeCodeCodec.generateNumericCode(CODE_LENGTH); - String codeHash = hash(code); - - TicketVerification verification = TicketVerification.builder() - .id(UUID.randomUUID().toString()) - .ticketId(ticket.getId()) - .codeHash(codeHash) - .email(email) - .expiresAt(new Date(System.currentTimeMillis() + (verificationConfig.getCodeExpirySeconds() * 1000L))) - .build(); - ticketVerificationRepository.replaceCodeVerification(server, verification); - - try { - String serverName = server.getServerName() != null ? server.getServerName() : "Server"; - EmailHTMLTemplate.HTMLEmail emailContent = EmailHTMLTemplate.TICKET_VERIFICATION_CODE.build(serverName, code); - emailService.send(email, emailContent); - } catch (Exception e) { - log.error("Failed to send verification code email for ticket {}", ticket.getId(), e); - throw new ExternalServiceException("Failed to send verification email", e); - } - - return EmailAddressUtil.mask(email); - } - - private String getCreatorEmail(Ticket ticket) { - String email = resolveContactEmail(ticket); - if (email == null) { - return null; - } - - String normalizedEmail = EmailAddressUtil.normalizeIfValid(email); - if (normalizedEmail == null) { - log.warn("Skipping ticket verification email for {} due to invalid contact email: {}", ticket.getId(), email); - } - - return normalizedEmail; - } - - public static String resolveContactEmail(Ticket ticket) { - if (ticket.getData() == null) { - return null; - } - Object email = ticket.getData().get(CREATOR_EMAIL_KEY); - if (email == null) { - email = ticket.getData().get(CONTACT_EMAIL_KEY); - } - if (email == null) { - return null; - } - String value = email.toString(); - return value.isBlank() ? null : value; - } - - private String hash(String code) { - return oneTimeCodeCodec.hash(code, verificationConfig.getCodeHashSecret()); - } - - public String verifyCode(Server server, String ticketId, String code) { - String codeHash = hash(code); - Date now = new Date(); - TicketVerification verification = ticketVerificationRepository.consumeMatchingCode(server, ticketId, codeHash, now) - .orElse(null); - if (verification == null) { - ticketVerificationRepository.incrementFailedAttempts(server, ticketId, now); - return null; - } - - String token = UUID.randomUUID().toString(); - TicketVerification tokenVerification = TicketVerification.builder() - .id(UUID.randomUUID().toString()) - .ticketId(ticketId) - .token(token) - .email(verification.getEmail()) - .expiresAt(new Date(System.currentTimeMillis() + (verificationConfig.getTokenExpirySeconds() * 1000L))) - .build(); - ticketVerificationRepository.saveEntity(server, tokenVerification); - return token; - } - - public boolean validateToken(Server server, String ticketId, String token) { - if (token == null || token.isBlank()) { - return false; - } - return ticketVerificationRepository.existsActiveToken(server, ticketId, token, new Date()); - } - - public boolean validateAppealCreateToken(Server server, String punishmentId, String playerUuid, String token) { - return validateToken(server, appealCreateSubject(punishmentId, playerUuid), token); - } - - private static String appealCreateSubject(String punishmentId, String playerUuid) { - return "appeal-create:" + punishmentId + ":" + (playerUuid == null ? "" : playerUuid); - } -} +package gg.modl.backend.ticket.service; + +import gg.modl.backend.database.mongo.repository.TicketVerificationMongoRepository; +import gg.modl.backend.email.EmailAddressUtil; +import gg.modl.backend.email.EmailHTMLTemplate; +import gg.modl.backend.email.EmailService; +import gg.modl.backend.infrastructure.onetimecode.OneTimeCodeCodec; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketVerification; +import gg.modl.backend.infrastructure.exception.ExternalServiceException; +import gg.modl.backend.infrastructure.exception.ValidationException; +import java.util.Date; +import java.util.UUID; +import gg.modl.backend.ticket.config.TicketEmailVerificationConfiguration; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TicketEmailVerificationService { + private final TicketVerificationMongoRepository ticketVerificationRepository; + private final EmailService emailService; + private final TicketEmailVerificationConfiguration verificationConfig; + private final OneTimeCodeCodec oneTimeCodeCodec; + private static final int CODE_LENGTH = 6; + private static final String CREATOR_EMAIL_KEY = "creatorEmail"; + private static final String CONTACT_EMAIL_KEY = "contactEmail"; + + public String sendVerificationCode(Server server, Ticket ticket) { + String email = getCreatorEmail(ticket); + if (email == null || email.isBlank()) { + throw new ValidationException("No valid email associated with this ticket"); + } + + String code = oneTimeCodeCodec.generateNumericCode(CODE_LENGTH); + String codeHash = hash(code); + + TicketVerification verification = TicketVerification.builder() + .id(UUID.randomUUID().toString()) + .ticketId(ticket.getId()) + .codeHash(codeHash) + .email(email) + .expiresAt(new Date(System.currentTimeMillis() + (verificationConfig.getCodeExpirySeconds() * 1000L))) + .build(); + ticketVerificationRepository.replaceCodeVerification(server, verification); + + try { + String serverName = server.getServerName() != null ? server.getServerName() : "Server"; + EmailHTMLTemplate.HTMLEmail emailContent = EmailHTMLTemplate.TICKET_VERIFICATION_CODE.build(serverName, code); + emailService.send(email, emailContent); + } catch (Exception e) { + log.error("Failed to send verification code email for ticket {}", ticket.getId(), e); + throw new ExternalServiceException("Failed to send verification email", e); + } + + return EmailAddressUtil.mask(email); + } + + private String getCreatorEmail(Ticket ticket) { + String email = resolveContactEmail(ticket); + if (email == null) { + return null; + } + + String normalizedEmail = EmailAddressUtil.normalizeIfValid(email); + if (normalizedEmail == null) { + log.warn("Skipping ticket verification email for {} due to invalid contact email: {}", ticket.getId(), email); + } + + return normalizedEmail; + } + + public static String resolveContactEmail(Ticket ticket) { + if (ticket.getData() == null) { + return null; + } + Object email = ticket.getData().get(CREATOR_EMAIL_KEY); + if (email == null) { + email = ticket.getData().get(CONTACT_EMAIL_KEY); + } + if (email == null) { + return null; + } + String value = email.toString(); + return value.isBlank() ? null : value; + } + + private String hash(String code) { + return oneTimeCodeCodec.hash(code, verificationConfig.getCodeHashSecret()); + } + + public String verifyCode(Server server, String ticketId, String code) { + String codeHash = hash(code); + Date now = new Date(); + TicketVerification verification = ticketVerificationRepository.consumeMatchingCode(server, ticketId, codeHash, now) + .orElse(null); + if (verification == null) { + ticketVerificationRepository.incrementFailedAttempts(server, ticketId, now); + return null; + } + + String token = UUID.randomUUID().toString(); + TicketVerification tokenVerification = TicketVerification.builder() + .id(UUID.randomUUID().toString()) + .ticketId(ticketId) + .token(token) + .email(verification.getEmail()) + .expiresAt(new Date(System.currentTimeMillis() + (verificationConfig.getTokenExpirySeconds() * 1000L))) + .build(); + ticketVerificationRepository.saveEntity(server, tokenVerification); + return token; + } + + public boolean validateToken(Server server, String ticketId, String token) { + if (token == null || token.isBlank()) { + return false; + } + return ticketVerificationRepository.existsActiveToken(server, ticketId, token, new Date()); + } + + public boolean validateAppealCreateToken(Server server, String punishmentId, String playerUuid, String token) { + return validateToken(server, appealCreateSubject(punishmentId, playerUuid), token); + } + + private static String appealCreateSubject(String punishmentId, String playerUuid) { + return "appeal-create:" + punishmentId + ":" + (playerUuid == null ? "" : playerUuid); + } +} diff --git a/src/main/java/gg/modl/backend/ticket/service/TicketReplyService.java b/src/main/java/gg/modl/backend/ticket/service/TicketReplyService.java index d576e26..98d34c5 100644 --- a/src/main/java/gg/modl/backend/ticket/service/TicketReplyService.java +++ b/src/main/java/gg/modl/backend/ticket/service/TicketReplyService.java @@ -1,127 +1,127 @@ -package gg.modl.backend.ticket.service; - -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.infrastructure.exception.ConflictException; -import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketNote; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.dto.request.AddNoteRequest; -import gg.modl.backend.ticket.dto.request.AddReplyRequest; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TicketReplyService { - private final TicketMongoRepository ticketRepository; - private final TicketNotificationService notificationService; - private final TicketContentService contentService; - - public TicketReply addReply(Server server, String ticketId, AddReplyRequest request) { - Ticket ticket = ticketRepository.findById(server, ticketId) - .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); - - if (ticket.isLocked()) { - throw new ConflictException("Ticket is locked and cannot accept new replies"); - } - - TicketReply newReply = TicketReply.builder() - .id(UUID.randomUUID().toString()) - .name(request.name()) - .avatar(request.avatar()) - .content(request.content()) - .type(request.type() != null ? request.type() : "public") - .created(new Date()) - .staff(request.staff()) - .action(request.action()) - .attachments(request.attachments() != null ? request.attachments() : new ArrayList<>()) - .creatorIdentifier(request.creatorIdentifier()) - .build(); - ticket.ensureReplies().add(newReply); - ticket.setUpdatedAt(new Date()); - Ticket saved = ticketRepository.saveEntity(server, ticket); - - if (request.staff()) { - notificationService.notifyTicketReply(server, saved, newReply); - } - - return newReply; - } - - public TicketReply addPublicReply(Server server, String ticketId, String content, List attachments) { - Ticket ticket = ticketRepository.findById(server, ticketId) - .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); - - if (ticket.isLocked()) { - throw new ConflictException("Ticket is locked and cannot accept new replies"); - } - - TicketReply newReply = TicketReply.builder() - .id(UUID.randomUUID().toString()) - .name(ticket.getCreatorName()) - .content(content) - .type("user") - .created(new Date()) - .staff(false) - .attachments(contentService.normalizeAttachments(attachments)) - .build(); - ticket.ensureReplies().add(newReply); - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - - return newReply; - } - - public TicketNote addNote(Server server, String ticketId, AddNoteRequest request) { - Ticket ticket = ticketRepository.findById(server, ticketId) - .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); - - TicketNote newNote = TicketNote.builder() - .text(request.text()) - .issuerName(request.issuerName()) - .issuerAvatar(request.issuerAvatar()) - .date(new Date()) - .build(); - ticket.ensureNotes().add(newNote); - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - - return newNote; - } - - public List addTag(Server server, String ticketId, String tag) { - Ticket ticket = ticketRepository.findById(server, ticketId) - .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); - - List tags = ticket.getTags() != null ? new ArrayList<>(ticket.getTags()) : new ArrayList<>(); - if (!tags.contains(tag)) { - tags.add(tag); - ticket.setTags(tags); - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - } - - return tags; - } - - public List removeTag(Server server, String ticketId, String tag) { - Ticket ticket = ticketRepository.findById(server, ticketId) - .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); - - List tags = ticket.getTags() != null ? new ArrayList<>(ticket.getTags()) : new ArrayList<>(); - if (tags.remove(tag)) { - ticket.setTags(tags); - ticket.setUpdatedAt(new Date()); - ticketRepository.saveEntity(server, ticket); - } - - return tags; - } - -} +package gg.modl.backend.ticket.service; + +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.infrastructure.exception.ConflictException; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketNote; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.dto.request.AddNoteRequest; +import gg.modl.backend.ticket.dto.request.AddReplyRequest; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TicketReplyService { + private final TicketMongoRepository ticketRepository; + private final TicketNotificationService notificationService; + private final TicketContentService contentService; + + public TicketReply addReply(Server server, String ticketId, AddReplyRequest request) { + Ticket ticket = ticketRepository.findById(server, ticketId) + .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); + + if (ticket.isLocked()) { + throw new ConflictException("Ticket is locked and cannot accept new replies"); + } + + TicketReply newReply = TicketReply.builder() + .id(UUID.randomUUID().toString()) + .name(request.name()) + .avatar(request.avatar()) + .content(request.content()) + .type(request.type() != null ? request.type() : "public") + .created(new Date()) + .staff(request.staff()) + .action(request.action()) + .attachments(request.attachments() != null ? request.attachments() : new ArrayList<>()) + .creatorIdentifier(request.creatorIdentifier()) + .build(); + ticket.ensureReplies().add(newReply); + ticket.setUpdatedAt(new Date()); + Ticket saved = ticketRepository.saveEntity(server, ticket); + + if (request.staff()) { + notificationService.notifyTicketReply(server, saved, newReply); + } + + return newReply; + } + + public TicketReply addPublicReply(Server server, String ticketId, String content, List attachments) { + Ticket ticket = ticketRepository.findById(server, ticketId) + .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); + + if (ticket.isLocked()) { + throw new ConflictException("Ticket is locked and cannot accept new replies"); + } + + TicketReply newReply = TicketReply.builder() + .id(UUID.randomUUID().toString()) + .name(ticket.getCreatorName()) + .content(content) + .type("user") + .created(new Date()) + .staff(false) + .attachments(contentService.normalizeAttachments(attachments)) + .build(); + ticket.ensureReplies().add(newReply); + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + + return newReply; + } + + public TicketNote addNote(Server server, String ticketId, AddNoteRequest request) { + Ticket ticket = ticketRepository.findById(server, ticketId) + .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); + + TicketNote newNote = TicketNote.builder() + .text(request.text()) + .issuerName(request.issuerName()) + .issuerAvatar(request.issuerAvatar()) + .date(new Date()) + .build(); + ticket.ensureNotes().add(newNote); + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + + return newNote; + } + + public List addTag(Server server, String ticketId, String tag) { + Ticket ticket = ticketRepository.findById(server, ticketId) + .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); + + List tags = ticket.getTags() != null ? new ArrayList<>(ticket.getTags()) : new ArrayList<>(); + if (!tags.contains(tag)) { + tags.add(tag); + ticket.setTags(tags); + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + } + + return tags; + } + + public List removeTag(Server server, String ticketId, String tag) { + Ticket ticket = ticketRepository.findById(server, ticketId) + .orElseThrow(() -> new ResourceNotFoundException("Ticket not found")); + + List tags = ticket.getTags() != null ? new ArrayList<>(ticket.getTags()) : new ArrayList<>(); + if (tags.remove(tag)) { + ticket.setTags(tags); + ticket.setUpdatedAt(new Date()); + ticketRepository.saveEntity(server, ticket); + } + + return tags; + } + +} diff --git a/src/main/java/gg/modl/backend/ticket/service/TicketSearchService.java b/src/main/java/gg/modl/backend/ticket/service/TicketSearchService.java index 8105c52..38b290d 100644 --- a/src/main/java/gg/modl/backend/ticket/service/TicketSearchService.java +++ b/src/main/java/gg/modl/backend/ticket/service/TicketSearchService.java @@ -1,143 +1,143 @@ -package gg.modl.backend.ticket.service; - -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import gg.modl.backend.ticket.dto.response.PaginatedTicketsResponse; -import gg.modl.backend.infrastructure.util.PaginationHelper; -import gg.modl.backend.ticket.dto.response.PlayerTicketResponse; -import gg.modl.backend.ticket.dto.response.TicketListItemResponse; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class TicketSearchService { - private final TicketMongoRepository ticketRepository; - - public PaginatedTicketsResponse searchTickets(Server server, int page, int limit, String search, String status, String type) { - List types = type != null && !type.isBlank() ? List.of(type) : null; - return searchTickets(server, page, limit, search, status, types, null, null, null, "newest"); - } - - public PaginatedTicketsResponse searchTickets(Server server, int page, int limit, String search, String status, List types, - String author, List labels, List assignees, String sort) { - TicketMongoRepository.TicketSearchFilter filter = new TicketMongoRepository.TicketSearchFilter( - search, - status, - types, - author, - labels, - assignees - ); - TicketMongoRepository.TicketSearchPage searchPage = ticketRepository.searchTickets( - server, - filter, - TicketMongoRepository.TicketSortOption.from(sort), - page, - limit - ); - - List ticketItems = searchPage.tickets() - .stream() - .map(this::toListItemResponse) - .toList(); - - int totalPages = PaginationHelper.calculateTotalPages(searchPage.total(), limit); - - return new PaginatedTicketsResponse( - ticketItems, - new PaginatedTicketsResponse.PaginationInfo( - page, - totalPages, - limit, - searchPage.total(), - page < totalPages, - page > 1 - ), - new PaginatedTicketsResponse.FiltersInfo(search, status, types) - ); - } - - private TicketListItemResponse toListItemResponse(Ticket ticket) { - TicketReply lastReply = null; - int replyCount = 0; - String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unknown"; - - if (ticket.getReplies() != null && !ticket.getReplies().isEmpty()) { - replyCount = ticket.getReplies().size(); - lastReply = ticket.getReplies().get(replyCount - 1); - } - - return new TicketListItemResponse( - ticket.getId(), - ticket.getSubject() != null ? ticket.getSubject() : "No Subject", - ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), - creatorName, - creatorName, - ticket.getCreated(), - ticket.getType() != null ? ticket.getType().getDisplayName() : TicketCategory.SUPPORT.getDisplayName(), - ticket.isLocked(), - ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId(), - lastReply, - replyCount, - ticket.getTags() != null ? ticket.getTags() : new ArrayList<>(), - ticket.getAssignedTo() != null ? ticket.getAssignedTo() : List.of(), - ticket.isHidden() - ); - } - - public Map getTicketCounts(Server server, String search, List types, String author, List labels, List assignees) { - TicketMongoRepository.TicketCounts ticketCounts = ticketRepository.countTickets( - server, - new TicketMongoRepository.TicketSearchFilter(search, null, types, author, labels, assignees) - ); - - Map result = new HashMap<>(); - result.put("open", ticketCounts.open()); - result.put("closed", ticketCounts.closed()); - return result; - } - - public List getTicketsByPlayer(Server server, String playerUuid) { - return ticketRepository.findByPlayer(server, normalizeUuid(playerUuid)) - .stream() - .map(this::toPlayerTicketResponse) - .toList(); - } - - private PlayerTicketResponse toPlayerTicketResponse(Ticket ticket) { - return new PlayerTicketResponse( - ticket.getId(), - ticket.getSubject(), - ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), - ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId(), - ticket.getCreated(), - ticket.getCreatorName(), - ticket.getCreatorUuid(), - ticket.getReportedPlayer(), - ticket.getReportedPlayerUuid(), - ticket.isLocked(), - ticket.getTags() != null ? ticket.getTags() : List.of(), - ticket.getAssignedTo() != null ? ticket.getAssignedTo() : List.of() - ); - } - - public List getTicketsByTag(Server server, String tag) { - return ticketRepository.findByTag(server, tag) - .stream() - .map(this::toPlayerTicketResponse) - .toList(); - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(java.util.Locale.ROOT); - } -} +package gg.modl.backend.ticket.service; + +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.ticket.dto.response.PaginatedTicketsResponse; +import gg.modl.backend.infrastructure.util.PaginationHelper; +import gg.modl.backend.ticket.dto.response.PlayerTicketResponse; +import gg.modl.backend.ticket.dto.response.TicketListItemResponse; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TicketSearchService { + private final TicketMongoRepository ticketRepository; + + public PaginatedTicketsResponse searchTickets(Server server, int page, int limit, String search, String status, String type) { + List types = type != null && !type.isBlank() ? List.of(type) : null; + return searchTickets(server, page, limit, search, status, types, null, null, null, "newest"); + } + + public PaginatedTicketsResponse searchTickets(Server server, int page, int limit, String search, String status, List types, + String author, List labels, List assignees, String sort) { + TicketMongoRepository.TicketSearchFilter filter = new TicketMongoRepository.TicketSearchFilter( + search, + status, + types, + author, + labels, + assignees + ); + TicketMongoRepository.TicketSearchPage searchPage = ticketRepository.searchTickets( + server, + filter, + TicketMongoRepository.TicketSortOption.from(sort), + page, + limit + ); + + List ticketItems = searchPage.tickets() + .stream() + .map(this::toListItemResponse) + .toList(); + + int totalPages = PaginationHelper.calculateTotalPages(searchPage.total(), limit); + + return new PaginatedTicketsResponse( + ticketItems, + new PaginatedTicketsResponse.PaginationInfo( + page, + totalPages, + limit, + searchPage.total(), + page < totalPages, + page > 1 + ), + new PaginatedTicketsResponse.FiltersInfo(search, status, types) + ); + } + + private TicketListItemResponse toListItemResponse(Ticket ticket) { + TicketReply lastReply = null; + int replyCount = 0; + String creatorName = ticket.getCreatorName() != null ? ticket.getCreatorName() : "Unknown"; + + if (ticket.getReplies() != null && !ticket.getReplies().isEmpty()) { + replyCount = ticket.getReplies().size(); + lastReply = ticket.getReplies().get(replyCount - 1); + } + + return new TicketListItemResponse( + ticket.getId(), + ticket.getSubject() != null ? ticket.getSubject() : "No Subject", + ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), + creatorName, + creatorName, + ticket.getCreated(), + ticket.getType() != null ? ticket.getType().getDisplayName() : TicketCategory.SUPPORT.getDisplayName(), + ticket.isLocked(), + ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId(), + lastReply, + replyCount, + ticket.getTags() != null ? ticket.getTags() : new ArrayList<>(), + ticket.getAssignedTo() != null ? ticket.getAssignedTo() : List.of(), + ticket.isHidden() + ); + } + + public Map getTicketCounts(Server server, String search, List types, String author, List labels, List assignees) { + TicketMongoRepository.TicketCounts ticketCounts = ticketRepository.countTickets( + server, + new TicketMongoRepository.TicketSearchFilter(search, null, types, author, labels, assignees) + ); + + Map result = new HashMap<>(); + result.put("open", ticketCounts.open()); + result.put("closed", ticketCounts.closed()); + return result; + } + + public List getTicketsByPlayer(Server server, String playerUuid) { + return ticketRepository.findByPlayer(server, normalizeUuid(playerUuid)) + .stream() + .map(this::toPlayerTicketResponse) + .toList(); + } + + private PlayerTicketResponse toPlayerTicketResponse(Ticket ticket) { + return new PlayerTicketResponse( + ticket.getId(), + ticket.getSubject(), + ticket.getStatus() != null ? ticket.getStatus().getId() : TicketStatus.OPEN.getId(), + ticket.getType() != null ? ticket.getType().getId() : TicketCategory.SUPPORT.getId(), + ticket.getCreated(), + ticket.getCreatorName(), + ticket.getCreatorUuid(), + ticket.getReportedPlayer(), + ticket.getReportedPlayerUuid(), + ticket.isLocked(), + ticket.getTags() != null ? ticket.getTags() : List.of(), + ticket.getAssignedTo() != null ? ticket.getAssignedTo() : List.of() + ); + } + + public List getTicketsByTag(Server server, String tag) { + return ticketRepository.findByTag(server, tag) + .stream() + .map(this::toPlayerTicketResponse) + .toList(); + } + + private static String normalizeUuid(String value) { + return value == null ? null : value.toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/test/java/gg/modl/backend/admin/service/GlobalSystemServiceTest.java b/src/test/java/gg/modl/backend/admin/service/GlobalSystemServiceTest.java index 1fb5d71..64c93bd 100644 --- a/src/test/java/gg/modl/backend/admin/service/GlobalSystemServiceTest.java +++ b/src/test/java/gg/modl/backend/admin/service/GlobalSystemServiceTest.java @@ -1,129 +1,129 @@ -package gg.modl.backend.admin.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -import gg.modl.backend.admin.data.SystemConfig; -import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest; -import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest.GeneralConfigRequest; -import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest.SecurityConfigRequest; -import gg.modl.backend.database.mongo.repository.SystemConfigMongoRepository; -import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class GlobalSystemServiceTest { - - @Mock - private SystemConfigMongoRepository systemConfigRepository; - - @Mock - private SystemPromptMongoRepository systemPromptRepository; - - private GlobalSystemService globalSystemService; - - @BeforeEach - void setUp() { - globalSystemService = new GlobalSystemService( - systemConfigRepository, - systemPromptRepository - ); - } - - @Test - void getGeneralConfigOrDefaultReturnsDefaultsWhenMissing() { - when(systemConfigRepository.findMainConfig()).thenReturn(Optional.empty()); - - SystemConfig.GeneralConfig config = globalSystemService.getGeneralConfigOrDefault(); - - assertFalse(config.isMaintenanceMode()); - assertEquals("System under maintenance. Please check back later.", config.getMaintenanceMessage()); - } - - @Test - void getGeneralConfigOrDefaultReturnsStoredConfigWhenPresent() { - SystemConfig systemConfig = new SystemConfig(); - systemConfig.getGeneral().setMaintenanceMode(true); - systemConfig.getGeneral().setMaintenanceMessage("Maintenance in progress."); - when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(systemConfig)); - - SystemConfig.GeneralConfig config = globalSystemService.getGeneralConfigOrDefault(); - - assertTrue(config.isMaintenanceMode()); - assertEquals("Maintenance in progress.", config.getMaintenanceMessage()); - } - - @Test - void updateConfigPreservesUntouchedFieldsInGeneralSection() { - SystemConfig preSet = new SystemConfig(); - preSet.getGeneral().setMaintenanceMode(true); - preSet.getGeneral().setTimezone("America/New_York"); - preSet.getGeneral().setAdminEmail("ops@x.com"); - when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(preSet)); - when(systemConfigRepository.saveEntity(any())).thenAnswer(inv -> inv.getArgument(0)); - - UpdateSystemConfigRequest request = new UpdateSystemConfigRequest( - new GeneralConfigRequest("New Name", null, null, null, null, null), - null, null, null, null, null - ); - - SystemConfig saved = globalSystemService.updateConfig(request); - - assertEquals("New Name", saved.getGeneral().getSystemName()); - assertTrue(saved.getGeneral().isMaintenanceMode()); - assertEquals("America/New_York", saved.getGeneral().getTimezone()); - assertEquals("ops@x.com", saved.getGeneral().getAdminEmail()); - } - - @Test - void updateConfigPreservesIpWhitelistAndCorsOriginsWhenOmitted() { - SystemConfig preSet = new SystemConfig(); - preSet.getSecurity().setIpWhitelist(List.of("10.0.0.0/8")); - preSet.getSecurity().setCorsOrigins(List.of("https://a", "https://b")); - when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(preSet)); - when(systemConfigRepository.saveEntity(any())).thenAnswer(inv -> inv.getArgument(0)); - - UpdateSystemConfigRequest request = new UpdateSystemConfigRequest( - null, - null, - new SecurityConfigRequest(null, null, null, true, null, null, null, null), - null, null, null - ); - - SystemConfig saved = globalSystemService.updateConfig(request); - - assertTrue(saved.getSecurity().isRequireTwoFactor()); - assertEquals(List.of("10.0.0.0/8"), saved.getSecurity().getIpWhitelist()); - assertEquals(List.of("https://a", "https://b"), saved.getSecurity().getCorsOrigins()); - } - - @Test - void updateConfigDoesNotTouchSectionsNotProvided() { - SystemConfig preSet = new SystemConfig(); - preSet.getSecurity().setSessionTimeout(120); - preSet.getSecurity().setMaxLoginAttempts(9); - preSet.getPerformance().setCacheTtl(999); - when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(preSet)); - when(systemConfigRepository.saveEntity(any())).thenAnswer(inv -> inv.getArgument(0)); - - UpdateSystemConfigRequest request = new UpdateSystemConfigRequest( - new GeneralConfigRequest("Only General", null, null, null, null, null), - null, null, null, null, null - ); - - SystemConfig saved = globalSystemService.updateConfig(request); - - assertEquals(120, saved.getSecurity().getSessionTimeout()); - assertEquals(9, saved.getSecurity().getMaxLoginAttempts()); - assertEquals(999, saved.getPerformance().getCacheTtl()); - } -} +package gg.modl.backend.admin.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import gg.modl.backend.admin.data.SystemConfig; +import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest; +import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest.GeneralConfigRequest; +import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest.SecurityConfigRequest; +import gg.modl.backend.database.mongo.repository.SystemConfigMongoRepository; +import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class GlobalSystemServiceTest { + + @Mock + private SystemConfigMongoRepository systemConfigRepository; + + @Mock + private SystemPromptMongoRepository systemPromptRepository; + + private GlobalSystemService globalSystemService; + + @BeforeEach + void setUp() { + globalSystemService = new GlobalSystemService( + systemConfigRepository, + systemPromptRepository + ); + } + + @Test + void getGeneralConfigOrDefaultReturnsDefaultsWhenMissing() { + when(systemConfigRepository.findMainConfig()).thenReturn(Optional.empty()); + + SystemConfig.GeneralConfig config = globalSystemService.getGeneralConfigOrDefault(); + + assertFalse(config.isMaintenanceMode()); + assertEquals("System under maintenance. Please check back later.", config.getMaintenanceMessage()); + } + + @Test + void getGeneralConfigOrDefaultReturnsStoredConfigWhenPresent() { + SystemConfig systemConfig = new SystemConfig(); + systemConfig.getGeneral().setMaintenanceMode(true); + systemConfig.getGeneral().setMaintenanceMessage("Maintenance in progress."); + when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(systemConfig)); + + SystemConfig.GeneralConfig config = globalSystemService.getGeneralConfigOrDefault(); + + assertTrue(config.isMaintenanceMode()); + assertEquals("Maintenance in progress.", config.getMaintenanceMessage()); + } + + @Test + void updateConfigPreservesUntouchedFieldsInGeneralSection() { + SystemConfig preSet = new SystemConfig(); + preSet.getGeneral().setMaintenanceMode(true); + preSet.getGeneral().setTimezone("America/New_York"); + preSet.getGeneral().setAdminEmail("ops@x.com"); + when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(preSet)); + when(systemConfigRepository.saveEntity(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateSystemConfigRequest request = new UpdateSystemConfigRequest( + new GeneralConfigRequest("New Name", null, null, null, null, null), + null, null, null, null, null + ); + + SystemConfig saved = globalSystemService.updateConfig(request); + + assertEquals("New Name", saved.getGeneral().getSystemName()); + assertTrue(saved.getGeneral().isMaintenanceMode()); + assertEquals("America/New_York", saved.getGeneral().getTimezone()); + assertEquals("ops@x.com", saved.getGeneral().getAdminEmail()); + } + + @Test + void updateConfigPreservesIpWhitelistAndCorsOriginsWhenOmitted() { + SystemConfig preSet = new SystemConfig(); + preSet.getSecurity().setIpWhitelist(List.of("10.0.0.0/8")); + preSet.getSecurity().setCorsOrigins(List.of("https://a", "https://b")); + when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(preSet)); + when(systemConfigRepository.saveEntity(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateSystemConfigRequest request = new UpdateSystemConfigRequest( + null, + null, + new SecurityConfigRequest(null, null, null, true, null, null, null, null), + null, null, null + ); + + SystemConfig saved = globalSystemService.updateConfig(request); + + assertTrue(saved.getSecurity().isRequireTwoFactor()); + assertEquals(List.of("10.0.0.0/8"), saved.getSecurity().getIpWhitelist()); + assertEquals(List.of("https://a", "https://b"), saved.getSecurity().getCorsOrigins()); + } + + @Test + void updateConfigDoesNotTouchSectionsNotProvided() { + SystemConfig preSet = new SystemConfig(); + preSet.getSecurity().setSessionTimeout(120); + preSet.getSecurity().setMaxLoginAttempts(9); + preSet.getPerformance().setCacheTtl(999); + when(systemConfigRepository.findMainConfig()).thenReturn(Optional.of(preSet)); + when(systemConfigRepository.saveEntity(any())).thenAnswer(inv -> inv.getArgument(0)); + + UpdateSystemConfigRequest request = new UpdateSystemConfigRequest( + new GeneralConfigRequest("Only General", null, null, null, null, null), + null, null, null, null, null + ); + + SystemConfig saved = globalSystemService.updateConfig(request); + + assertEquals(120, saved.getSecurity().getSessionTimeout()); + assertEquals(9, saved.getSecurity().getMaxLoginAttempts()); + assertEquals(999, saved.getPerformance().getCacheTtl()); + } +} diff --git a/src/test/java/gg/modl/backend/auth/filter/SessionAuthenticationFilterTest.java b/src/test/java/gg/modl/backend/auth/filter/SessionAuthenticationFilterTest.java index e49c5e9..c9e27b5 100644 --- a/src/test/java/gg/modl/backend/auth/filter/SessionAuthenticationFilterTest.java +++ b/src/test/java/gg/modl/backend/auth/filter/SessionAuthenticationFilterTest.java @@ -1,103 +1,103 @@ -package gg.modl.backend.auth.filter; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import gg.modl.backend.auth.AuthConfiguration; -import gg.modl.backend.auth.session.AuthSessionData; -import gg.modl.backend.auth.session.SessionService; -import gg.modl.backend.infrastructure.filter.SessionAuthenticationFilter; -import gg.modl.backend.infrastructure.rest.RequestAttribute; -import gg.modl.backend.infrastructure.util.CookieUtil; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import jakarta.servlet.http.Cookie; -import java.util.Date; -import java.util.Optional; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockFilterChain; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; -import org.springframework.security.core.context.SecurityContextHolder; - -class SessionAuthenticationFilterTest { - - @AfterEach - void clearSecurityContext() { - SecurityContextHolder.clearContext(); - } - - @Test - void refreshesSessionCookieAsHostOnlyStrictInProduction() throws Exception { - SessionService sessionService = mock(SessionService.class); - - AuthConfiguration authConfiguration = new AuthConfiguration(); - authConfiguration.setDevelopmentMode(false); - authConfiguration.setCookieSecure(true); - authConfiguration.setSessionCookieName("MODL_SESSION"); - - CookieUtil cookieUtil = new CookieUtil(authConfiguration); - SessionAuthenticationFilter filter = new SessionAuthenticationFilter(sessionService, authConfiguration, cookieUtil); - - Server server = new Server("Alpha", "alpha", "server_alpha", "admin@example.com", true, ServerPlan.FREE); - AuthSessionData session = new AuthSessionData("token-123", "staff@example.com", new Date(), new Date(System.currentTimeMillis() + 1000), null, null); - - when(sessionService.findAndRefreshSession(server, "token-123")).thenReturn(Optional.of(session)); - - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/panel/auth/me"); - request.setAttribute(RequestAttribute.SERVER, server); - request.setCookies(new Cookie("MODL_SESSION", "token-123")); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - filter.doFilter(request, response, new MockFilterChain()); - - Cookie refreshedCookie = response.getCookie("MODL_SESSION"); - assertNotNull(refreshedCookie); - assertEquals("token-123", refreshedCookie.getValue()); - assertEquals((int) AuthConfiguration.MIN_SESSION_DURATION_SECONDS, refreshedCookie.getMaxAge()); - assertNull(refreshedCookie.getDomain()); - assertEquals("Strict", refreshedCookie.getAttribute("SameSite")); - - verify(sessionService).findAndRefreshSession(server, "token-123"); - } - - @Test - void refreshesSessionCookieWithLaxSameSiteInDevelopmentMode() throws Exception { - SessionService sessionService = mock(SessionService.class); - - AuthConfiguration authConfiguration = new AuthConfiguration(); - authConfiguration.setDevelopmentMode(true); - authConfiguration.setCookieSecure(true); - authConfiguration.setSessionCookieName("MODL_SESSION"); - - CookieUtil cookieUtil = new CookieUtil(authConfiguration); - SessionAuthenticationFilter filter = new SessionAuthenticationFilter(sessionService, authConfiguration, cookieUtil); - - Server server = new Server("Custom", "custom", "server_custom", "admin@example.com", true, ServerPlan.FREE); - AuthSessionData session = new AuthSessionData("token-456", "staff@example.com", new Date(), new Date(System.currentTimeMillis() + 1000), null, null); - - when(sessionService.findAndRefreshSession(server, "token-456")).thenReturn(Optional.of(session)); - - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/panel/auth/me"); - request.setAttribute(RequestAttribute.SERVER, server); - request.setCookies(new Cookie("MODL_SESSION", "token-456")); - - MockHttpServletResponse response = new MockHttpServletResponse(); - - filter.doFilter(request, response, new MockFilterChain()); - - Cookie refreshedCookie = response.getCookie("MODL_SESSION"); - assertNotNull(refreshedCookie); - assertEquals("token-456", refreshedCookie.getValue()); - assertNull(refreshedCookie.getDomain()); - assertEquals("Lax", refreshedCookie.getAttribute("SameSite")); - - verify(sessionService).findAndRefreshSession(server, "token-456"); - } -} +package gg.modl.backend.auth.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import gg.modl.backend.auth.AuthConfiguration; +import gg.modl.backend.auth.session.AuthSessionData; +import gg.modl.backend.auth.session.SessionService; +import gg.modl.backend.infrastructure.filter.SessionAuthenticationFilter; +import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.infrastructure.util.CookieUtil; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import jakarta.servlet.http.Cookie; +import java.util.Date; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.context.SecurityContextHolder; + +class SessionAuthenticationFilterTest { + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void refreshesSessionCookieAsHostOnlyStrictInProduction() throws Exception { + SessionService sessionService = mock(SessionService.class); + + AuthConfiguration authConfiguration = new AuthConfiguration(); + authConfiguration.setDevelopmentMode(false); + authConfiguration.setCookieSecure(true); + authConfiguration.setSessionCookieName("MODL_SESSION"); + + CookieUtil cookieUtil = new CookieUtil(authConfiguration); + SessionAuthenticationFilter filter = new SessionAuthenticationFilter(sessionService, authConfiguration, cookieUtil); + + Server server = new Server("Alpha", "alpha", "server_alpha", "admin@example.com", true, ServerPlan.FREE); + AuthSessionData session = new AuthSessionData("token-123", "staff@example.com", new Date(), new Date(System.currentTimeMillis() + 1000), null, null); + + when(sessionService.findAndRefreshSession(server, "token-123")).thenReturn(Optional.of(session)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/panel/auth/me"); + request.setAttribute(RequestAttribute.SERVER, server); + request.setCookies(new Cookie("MODL_SESSION", "token-123")); + + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + Cookie refreshedCookie = response.getCookie("MODL_SESSION"); + assertNotNull(refreshedCookie); + assertEquals("token-123", refreshedCookie.getValue()); + assertEquals((int) AuthConfiguration.MIN_SESSION_DURATION_SECONDS, refreshedCookie.getMaxAge()); + assertNull(refreshedCookie.getDomain()); + assertEquals("Strict", refreshedCookie.getAttribute("SameSite")); + + verify(sessionService).findAndRefreshSession(server, "token-123"); + } + + @Test + void refreshesSessionCookieWithLaxSameSiteInDevelopmentMode() throws Exception { + SessionService sessionService = mock(SessionService.class); + + AuthConfiguration authConfiguration = new AuthConfiguration(); + authConfiguration.setDevelopmentMode(true); + authConfiguration.setCookieSecure(true); + authConfiguration.setSessionCookieName("MODL_SESSION"); + + CookieUtil cookieUtil = new CookieUtil(authConfiguration); + SessionAuthenticationFilter filter = new SessionAuthenticationFilter(sessionService, authConfiguration, cookieUtil); + + Server server = new Server("Custom", "custom", "server_custom", "admin@example.com", true, ServerPlan.FREE); + AuthSessionData session = new AuthSessionData("token-456", "staff@example.com", new Date(), new Date(System.currentTimeMillis() + 1000), null, null); + + when(sessionService.findAndRefreshSession(server, "token-456")).thenReturn(Optional.of(session)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/panel/auth/me"); + request.setAttribute(RequestAttribute.SERVER, server); + request.setCookies(new Cookie("MODL_SESSION", "token-456")); + + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + + Cookie refreshedCookie = response.getCookie("MODL_SESSION"); + assertNotNull(refreshedCookie); + assertEquals("token-456", refreshedCookie.getValue()); + assertNull(refreshedCookie.getDomain()); + assertEquals("Lax", refreshedCookie.getAttribute("SameSite")); + + verify(sessionService).findAndRefreshSession(server, "token-456"); + } +} diff --git a/src/test/java/gg/modl/backend/billing/service/BillingServiceTest.java b/src/test/java/gg/modl/backend/billing/service/BillingServiceTest.java index b9912ba..e9b540a 100644 --- a/src/test/java/gg/modl/backend/billing/service/BillingServiceTest.java +++ b/src/test/java/gg/modl/backend/billing/service/BillingServiceTest.java @@ -1,62 +1,62 @@ -package gg.modl.backend.billing.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import com.stripe.model.checkout.Session; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.service.ServerMutationHelper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class BillingServiceTest { - - @Mock - private StripeService stripeService; - - @Mock - private ServerMutationHelper serverMutationHelper; - - @Mock - private PermissionService permissionService; - - private BillingService billingService; - - @BeforeEach - void setUp() { - billingService = new BillingService(stripeService, serverMutationHelper, permissionService); - } - - @Test - void createCheckoutSessionPersistsStripeCustomerIdThroughRepository() throws Exception { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - server.setId("server-1"); - - Session session = mock(Session.class); - when(stripeService.createCustomer(server)).thenReturn("cus_123"); - when(stripeService.createCheckoutSession("cus_123", server.getCustomDomain())).thenReturn(session); - when(session.getId()).thenReturn("sess_123"); - when(session.getUrl()).thenReturn("https://checkout.example.com"); - - doAnswer(invocation -> { - java.util.function.Consumer mutator = invocation.getArgument(1); - mutator.accept(invocation.getArgument(0)); - return null; - }).when(serverMutationHelper).mutate(any(Server.class), any()); - - billingService.createCheckoutSession(server); - - verify(serverMutationHelper).mutate(any(Server.class), any()); - assertEquals("cus_123", server.getStripeCustomerId()); - } -} +package gg.modl.backend.billing.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stripe.model.checkout.Session; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.service.ServerMutationHelper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class BillingServiceTest { + + @Mock + private StripeService stripeService; + + @Mock + private ServerMutationHelper serverMutationHelper; + + @Mock + private PermissionService permissionService; + + private BillingService billingService; + + @BeforeEach + void setUp() { + billingService = new BillingService(stripeService, serverMutationHelper, permissionService); + } + + @Test + void createCheckoutSessionPersistsStripeCustomerIdThroughRepository() throws Exception { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + server.setId("server-1"); + + Session session = mock(Session.class); + when(stripeService.createCustomer(server)).thenReturn("cus_123"); + when(stripeService.createCheckoutSession("cus_123", server.getCustomDomain())).thenReturn(session); + when(session.getId()).thenReturn("sess_123"); + when(session.getUrl()).thenReturn("https://checkout.example.com"); + + doAnswer(invocation -> { + java.util.function.Consumer mutator = invocation.getArgument(1); + mutator.accept(invocation.getArgument(0)); + return null; + }).when(serverMutationHelper).mutate(any(Server.class), any()); + + billingService.createCheckoutSession(server); + + verify(serverMutationHelper).mutate(any(Server.class), any()); + assertEquals("cus_123", server.getStripeCustomerId()); + } +} diff --git a/src/test/java/gg/modl/backend/billing/service/SubscriptionExpiryServiceTest.java b/src/test/java/gg/modl/backend/billing/service/SubscriptionExpiryServiceTest.java index 09013e4..8e5d255 100644 --- a/src/test/java/gg/modl/backend/billing/service/SubscriptionExpiryServiceTest.java +++ b/src/test/java/gg/modl/backend/billing/service/SubscriptionExpiryServiceTest.java @@ -1,65 +1,65 @@ -package gg.modl.backend.billing.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.data.SubscriptionStatus; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.Date; -import java.util.List; -import java.util.function.Consumer; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SubscriptionExpiryServiceTest { - - @Mock - private ServerMongoRepository serverRepository; - - @Mock - private UsageTrackingService usageTrackingService; - - @Mock - private ServerMutationHelper serverMutationHelper; - - private SubscriptionExpiryService subscriptionExpiryService; - - @BeforeEach - void setUp() { - subscriptionExpiryService = new SubscriptionExpiryService(serverRepository, usageTrackingService, serverMutationHelper); - } - - @Test - void checkExpiredSubscriptionsDowngradesExpiredCanceledServer() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.PREMIUM); - server.setId("server-1"); - server.setSubscriptionStatus(SubscriptionStatus.CANCELED); - server.setCurrentPeriodEnd(new Date(System.currentTimeMillis() - 1000)); - when(serverRepository.findCancelledWithPeriodEnd()).thenReturn(List.of(server)); - - doAnswer(invocation -> { - Consumer mutator = invocation.getArgument(1); - mutator.accept(invocation.getArgument(0)); - return null; - }).when(serverMutationHelper).mutate(any(Server.class), any()); - - subscriptionExpiryService.checkExpiredSubscriptions(); - - verify(serverMutationHelper).mutate(any(Server.class), any()); - assertEquals(SubscriptionStatus.INACTIVE, server.getSubscriptionStatus()); - assertEquals(ServerPlan.FREE, server.getPlan()); - assertNull(server.getCurrentPeriodEnd()); - verify(usageTrackingService).resetUsageCounters("server-1"); - } -} +package gg.modl.backend.billing.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.Date; +import java.util.List; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SubscriptionExpiryServiceTest { + + @Mock + private ServerMongoRepository serverRepository; + + @Mock + private UsageTrackingService usageTrackingService; + + @Mock + private ServerMutationHelper serverMutationHelper; + + private SubscriptionExpiryService subscriptionExpiryService; + + @BeforeEach + void setUp() { + subscriptionExpiryService = new SubscriptionExpiryService(serverRepository, usageTrackingService, serverMutationHelper); + } + + @Test + void checkExpiredSubscriptionsDowngradesExpiredCanceledServer() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.PREMIUM); + server.setId("server-1"); + server.setSubscriptionStatus(SubscriptionStatus.CANCELED); + server.setCurrentPeriodEnd(new Date(System.currentTimeMillis() - 1000)); + when(serverRepository.findCancelledWithPeriodEnd()).thenReturn(List.of(server)); + + doAnswer(invocation -> { + Consumer mutator = invocation.getArgument(1); + mutator.accept(invocation.getArgument(0)); + return null; + }).when(serverMutationHelper).mutate(any(Server.class), any()); + + subscriptionExpiryService.checkExpiredSubscriptions(); + + verify(serverMutationHelper).mutate(any(Server.class), any()); + assertEquals(SubscriptionStatus.INACTIVE, server.getSubscriptionStatus()); + assertEquals(ServerPlan.FREE, server.getPlan()); + assertNull(server.getCurrentPeriodEnd()); + verify(usageTrackingService).resetUsageCounters("server-1"); + } +} diff --git a/src/test/java/gg/modl/backend/billing/service/UsageTrackingServiceTest.java b/src/test/java/gg/modl/backend/billing/service/UsageTrackingServiceTest.java index a4b4582..75b8c1a 100644 --- a/src/test/java/gg/modl/backend/billing/service/UsageTrackingServiceTest.java +++ b/src/test/java/gg/modl/backend/billing/service/UsageTrackingServiceTest.java @@ -1,55 +1,55 @@ -package gg.modl.backend.billing.service; - -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.verify; - -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.limits.DefaultServerLimitPolicy; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.server.service.ServerMutationHelper; -import java.util.function.Consumer; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class UsageTrackingServiceTest { - - @Mock - private ServerMongoRepository serverRepository; - - @Mock - private ServerMutationHelper serverMutationHelper; - - private UsageTrackingService usageTrackingService; - - @BeforeEach - void setUp() { - usageTrackingService = new UsageTrackingService(serverRepository, serverMutationHelper, new DefaultServerLimitPolicy()); - } - - @Test - void updateUsageBillingSettingsPersistsFlagsThroughRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.PREMIUM); - server.setId("server-1"); - server.setStripeCustomerId("cus_123"); - - doAnswer(invocation -> { - Consumer mutator = invocation.getArgument(1); - mutator.accept(invocation.getArgument(0)); - return null; - }).when(serverMutationHelper).mutate(any(Server.class), any()); - - usageTrackingService.updateUsageBillingSettings(server, true); - - verify(serverMutationHelper).mutate(any(Server.class), any()); - assertTrue(Boolean.TRUE.equals(server.getUsageBillingEnabled())); - assertTrue(server.getUsageBillingUpdatedAt() != null); - } - -} +package gg.modl.backend.billing.service; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.limits.DefaultServerLimitPolicy; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.service.ServerMutationHelper; +import java.util.function.Consumer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UsageTrackingServiceTest { + + @Mock + private ServerMongoRepository serverRepository; + + @Mock + private ServerMutationHelper serverMutationHelper; + + private UsageTrackingService usageTrackingService; + + @BeforeEach + void setUp() { + usageTrackingService = new UsageTrackingService(serverRepository, serverMutationHelper, new DefaultServerLimitPolicy()); + } + + @Test + void updateUsageBillingSettingsPersistsFlagsThroughRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.PREMIUM); + server.setId("server-1"); + server.setStripeCustomerId("cus_123"); + + doAnswer(invocation -> { + Consumer mutator = invocation.getArgument(1); + mutator.accept(invocation.getArgument(0)); + return null; + }).when(serverMutationHelper).mutate(any(Server.class), any()); + + usageTrackingService.updateUsageBillingSettings(server, true); + + verify(serverMutationHelper).mutate(any(Server.class), any()); + assertTrue(Boolean.TRUE.equals(server.getUsageBillingEnabled())); + assertTrue(server.getUsageBillingUpdatedAt() != null); + } + +} diff --git a/src/test/java/gg/modl/backend/dashboard/service/DashboardServiceTest.java b/src/test/java/gg/modl/backend/dashboard/service/DashboardServiceTest.java index 93bab81..f02c24c 100644 --- a/src/test/java/gg/modl/backend/dashboard/service/DashboardServiceTest.java +++ b/src/test/java/gg/modl/backend/dashboard/service/DashboardServiceTest.java @@ -1,196 +1,196 @@ -package gg.modl.backend.dashboard.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -import gg.modl.backend.dashboard.dto.response.MinecraftDashboardStatsResponse; -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.service.PlayerStatusCalculator; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.staff.service.StaffService; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class DashboardServiceTest { - - @Mock - private TicketMongoRepository ticketRepository; - - @Mock - private PlayerMongoRepository playerRepository; - - @Mock - private PunishmentMongoRepository punishmentRepository; - - @Mock - private StaffMongoRepository staffRepository; - - @Mock - private StaffService staffService; - - @Mock - private PunishmentTypeService punishmentTypeService; - - @Mock - private PlayerStatusCalculator statusCalculator; - - @Mock - private Server server; - - private DashboardService dashboardService; - - @BeforeEach - void setUp() { - dashboardService = new DashboardService( - ticketRepository, - playerRepository, - punishmentRepository, - staffRepository, - staffService, - punishmentTypeService, - statusCalculator - ); - } - - @Test - void getMinecraftStatsAggregatesRepositoryResults() { - when(server.getId()).thenReturn("test-server-id"); - List staffUuids = List.of("uuid-1", "uuid-2"); - - Player punishedPlayer = Player.builder() - .minecraftUuid(UUID.fromString("11111111-1111-1111-1111-111111111111")) - .punishments(List.of( - punishment("active-ban", 2, "spam"), - punishment("active-mute", 1, "toxicity") - )) - .build(); - Player inactivePlayer = Player.builder() - .minecraftUuid(UUID.fromString("22222222-2222-2222-2222-222222222222")) - .punishments(List.of(punishment("inactive-ban", 2, "expired"))) - .build(); - - when(ticketRepository.countUnresolvedReports(server)).thenReturn(3L); - when(ticketRepository.countUnresolvedTickets(server)).thenReturn(4L); - when(staffRepository.findAssignedMinecraftUuids(server)).thenReturn(staffUuids); - when(playerRepository.countOnlineByUuids(eq(server), eq(staffUuids))).thenReturn(2L); - when(playerRepository.countOnlinePlayers(server)).thenReturn(12L); - when(playerRepository.countAll(server)).thenReturn(50L); - when(punishmentRepository.findWithPunishmentsProjected(server)).thenReturn(List.of(punishedPlayer, inactivePlayer)); - when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of( - punishmentType("Mute", 1, "Social"), - punishmentType("Ban", 2, "Administrative") - )); - when(statusCalculator.isPunishmentActive(any(Punishment.class))).thenAnswer(invocation -> - !((Punishment) invocation.getArgument(0)).getId().startsWith("inactive") - ); - when(statusCalculator.getEffectiveCategory(any(Punishment.class), any(Map.class))).thenAnswer(invocation -> { - Punishment p = invocation.getArgument(0); - if (p.getId().contains("ban")) { - return "BAN"; - } - if (p.getId().contains("mute")) { - return "MUTE"; - } - return null; - }); - - MinecraftDashboardStatsResponse response = dashboardService.getMinecraftStats(server); - - assertEquals(3L, response.unresolvedReports()); - assertEquals(4L, response.unresolvedTickets()); - assertEquals(2L, response.onlineStaff()); - assertEquals(12L, response.onlinePlayers()); - assertEquals(1L, response.activeBans()); - assertEquals(1L, response.activeMutes()); - assertEquals(2L, response.totalActivePunishments()); - assertEquals(50L, response.totalPlayers()); - } - - @Test - void getMetricsComputesPunishmentAndPlayerTrends() { - when(server.getId()).thenReturn("test-server-id"); - - Player punishedPlayer = Player.builder() - .minecraftUuid(UUID.fromString("33333333-3333-3333-3333-333333333333")) - .punishments(List.of( - punishment("active-ban", 2, "spam"), - punishment("active-mute", 1, "toxicity") - )) - .build(); - - when(ticketRepository.countAll(server)).thenReturn(40L); - when(ticketRepository.countByStatus(eq(server), any())).thenReturn(5L); - when(playerRepository.countAll(server)).thenReturn(100L); - when(staffService.countStaffIncludingSuperAdmin(server)).thenReturn(8L); - when(ticketRepository.countCreatedAfter(eq(server), any(Date.class))).thenReturn(12L); - when(ticketRepository.countCreatedBetween(eq(server), any(Date.class), any(Date.class))).thenReturn(10L); - when(punishmentRepository.countAllPunishments(server)).thenReturn(77L); - when(playerRepository.countFirstJoinedAfter(eq(server), any(Date.class))).thenReturn(15L); - when(playerRepository.countFirstJoinedBetween(eq(server), any(Date.class), any(Date.class))).thenReturn(10L); - - when(punishmentRepository.findWithPunishmentsProjected(server)).thenReturn(List.of(punishedPlayer)); - when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of( - punishmentType("Mute", 1, "Social"), - punishmentType("Ban", 2, "Administrative") - )); - when(statusCalculator.isPunishmentActive(any(Punishment.class))).thenReturn(true); - when(statusCalculator.getEffectiveCategory(any(Punishment.class), any(Map.class))).thenAnswer(invocation -> { - Punishment p = invocation.getArgument(0); - if (p.getId().contains("ban")) { - return "BAN"; - } - if (p.getId().contains("mute")) { - return "MUTE"; - } - return null; - }); - - var metrics = dashboardService.getMetrics(server, "7d"); - - assertEquals(77L, metrics.totalPunishments()); - assertEquals(2L, metrics.activePunishments()); - assertEquals(50, metrics.playersTrend()); - } - - private static Punishment punishment(String id, int ordinal, String reason) { - return new Punishment( - id, - ordinal, - "Moderator", - null, - new Date(), - null, - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - Map.of("reason", reason) - ); - } - - private static PunishmentType punishmentType(String name, int ordinal, String category) { - PunishmentType punishmentType = new PunishmentType(); - punishmentType.setName(name); - punishmentType.setOrdinal(ordinal); - punishmentType.setCategory(category); - return punishmentType; - } -} +package gg.modl.backend.dashboard.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import gg.modl.backend.dashboard.dto.response.MinecraftDashboardStatsResponse; +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.service.PlayerStatusCalculator; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.data.PunishmentType; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.staff.service.StaffService; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class DashboardServiceTest { + + @Mock + private TicketMongoRepository ticketRepository; + + @Mock + private PlayerMongoRepository playerRepository; + + @Mock + private PunishmentMongoRepository punishmentRepository; + + @Mock + private StaffMongoRepository staffRepository; + + @Mock + private StaffService staffService; + + @Mock + private PunishmentTypeService punishmentTypeService; + + @Mock + private PlayerStatusCalculator statusCalculator; + + @Mock + private Server server; + + private DashboardService dashboardService; + + @BeforeEach + void setUp() { + dashboardService = new DashboardService( + ticketRepository, + playerRepository, + punishmentRepository, + staffRepository, + staffService, + punishmentTypeService, + statusCalculator + ); + } + + @Test + void getMinecraftStatsAggregatesRepositoryResults() { + when(server.getId()).thenReturn("test-server-id"); + List staffUuids = List.of("uuid-1", "uuid-2"); + + Player punishedPlayer = Player.builder() + .minecraftUuid(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .punishments(List.of( + punishment("active-ban", 2, "spam"), + punishment("active-mute", 1, "toxicity") + )) + .build(); + Player inactivePlayer = Player.builder() + .minecraftUuid(UUID.fromString("22222222-2222-2222-2222-222222222222")) + .punishments(List.of(punishment("inactive-ban", 2, "expired"))) + .build(); + + when(ticketRepository.countUnresolvedReports(server)).thenReturn(3L); + when(ticketRepository.countUnresolvedTickets(server)).thenReturn(4L); + when(staffRepository.findAssignedMinecraftUuids(server)).thenReturn(staffUuids); + when(playerRepository.countOnlineByUuids(eq(server), eq(staffUuids))).thenReturn(2L); + when(playerRepository.countOnlinePlayers(server)).thenReturn(12L); + when(playerRepository.countAll(server)).thenReturn(50L); + when(punishmentRepository.findWithPunishmentsProjected(server)).thenReturn(List.of(punishedPlayer, inactivePlayer)); + when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of( + punishmentType("Mute", 1, "Social"), + punishmentType("Ban", 2, "Administrative") + )); + when(statusCalculator.isPunishmentActive(any(Punishment.class))).thenAnswer(invocation -> + !((Punishment) invocation.getArgument(0)).getId().startsWith("inactive") + ); + when(statusCalculator.getEffectiveCategory(any(Punishment.class), any(Map.class))).thenAnswer(invocation -> { + Punishment p = invocation.getArgument(0); + if (p.getId().contains("ban")) { + return "BAN"; + } + if (p.getId().contains("mute")) { + return "MUTE"; + } + return null; + }); + + MinecraftDashboardStatsResponse response = dashboardService.getMinecraftStats(server); + + assertEquals(3L, response.unresolvedReports()); + assertEquals(4L, response.unresolvedTickets()); + assertEquals(2L, response.onlineStaff()); + assertEquals(12L, response.onlinePlayers()); + assertEquals(1L, response.activeBans()); + assertEquals(1L, response.activeMutes()); + assertEquals(2L, response.totalActivePunishments()); + assertEquals(50L, response.totalPlayers()); + } + + @Test + void getMetricsComputesPunishmentAndPlayerTrends() { + when(server.getId()).thenReturn("test-server-id"); + + Player punishedPlayer = Player.builder() + .minecraftUuid(UUID.fromString("33333333-3333-3333-3333-333333333333")) + .punishments(List.of( + punishment("active-ban", 2, "spam"), + punishment("active-mute", 1, "toxicity") + )) + .build(); + + when(ticketRepository.countAll(server)).thenReturn(40L); + when(ticketRepository.countByStatus(eq(server), any())).thenReturn(5L); + when(playerRepository.countAll(server)).thenReturn(100L); + when(staffService.countStaffIncludingSuperAdmin(server)).thenReturn(8L); + when(ticketRepository.countCreatedAfter(eq(server), any(Date.class))).thenReturn(12L); + when(ticketRepository.countCreatedBetween(eq(server), any(Date.class), any(Date.class))).thenReturn(10L); + when(punishmentRepository.countAllPunishments(server)).thenReturn(77L); + when(playerRepository.countFirstJoinedAfter(eq(server), any(Date.class))).thenReturn(15L); + when(playerRepository.countFirstJoinedBetween(eq(server), any(Date.class), any(Date.class))).thenReturn(10L); + + when(punishmentRepository.findWithPunishmentsProjected(server)).thenReturn(List.of(punishedPlayer)); + when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of( + punishmentType("Mute", 1, "Social"), + punishmentType("Ban", 2, "Administrative") + )); + when(statusCalculator.isPunishmentActive(any(Punishment.class))).thenReturn(true); + when(statusCalculator.getEffectiveCategory(any(Punishment.class), any(Map.class))).thenAnswer(invocation -> { + Punishment p = invocation.getArgument(0); + if (p.getId().contains("ban")) { + return "BAN"; + } + if (p.getId().contains("mute")) { + return "MUTE"; + } + return null; + }); + + var metrics = dashboardService.getMetrics(server, "7d"); + + assertEquals(77L, metrics.totalPunishments()); + assertEquals(2L, metrics.activePunishments()); + assertEquals(50, metrics.playersTrend()); + } + + private static Punishment punishment(String id, int ordinal, String reason) { + return new Punishment( + id, + ordinal, + "Moderator", + null, + new Date(), + null, + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + Map.of("reason", reason) + ); + } + + private static PunishmentType punishmentType(String name, int ordinal, String category) { + PunishmentType punishmentType = new PunishmentType(); + punishmentType.setName(name); + punishmentType.setOrdinal(ordinal); + punishmentType.setCategory(category); + return punishmentType; + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftDashboardApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftDashboardApiTest.java index c0a6846..d4a8b4e 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftDashboardApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftDashboardApiTest.java @@ -1,33 +1,33 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftDashboardApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void getDashboardStats() throws Exception { - var response = api.minecraftGet("/v1/minecraft/dashboard/stats"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("stats")); - var stats = json.getAsJsonObject("stats"); - assertTrue(stats.has("totalPlayers")); - assertTrue(stats.has("onlinePlayers")); - assertTrue(stats.has("activeBans")); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftDashboardApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void getDashboardStats() throws Exception { + var response = api.minecraftGet("/v1/minecraft/dashboard/stats"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("stats")); + var stats = json.getAsJsonObject("stats"); + assertTrue(stats.has("totalPlayers")); + assertTrue(stats.has("onlinePlayers")); + assertTrue(stats.has("activeBans")); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftMigrationApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftMigrationApiTest.java index 197ed08..0cf623a 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftMigrationApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftMigrationApiTest.java @@ -1,39 +1,39 @@ -package gg.modl.backend.minecraft; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class MinecraftMigrationApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Disabled("Skipped: migration upload requires multipart file upload which changes staging state") - @Test - void uploadMigrationFile() throws Exception { - // Would require multipart upload - skipped for safety - } - - @Disabled("Skipped: migration progress report requires valid migration state on staging") - @Test - void reportProgress() throws Exception { - var response = api.minecraftPost("/v1/minecraft/migration/progress", Map.of( - "status", "IDLE", - "message", "API test progress report", - "processed", 0, - "total", 0 - )); - JsonHelper.assertStatus(response, 200); - } -} +package gg.modl.backend.minecraft; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class MinecraftMigrationApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Disabled("Skipped: migration upload requires multipart file upload which changes staging state") + @Test + void uploadMigrationFile() throws Exception { + // Would require multipart upload - skipped for safety + } + + @Disabled("Skipped: migration progress report requires valid migration state on staging") + @Test + void reportProgress() throws Exception { + var response = api.minecraftPost("/v1/minecraft/migration/progress", Map.of( + "status", "IDLE", + "message", "API test progress report", + "processed", 0, + "total", 0 + )); + JsonHelper.assertStatus(response, 200); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftNotificationApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftNotificationApiTest.java index e222a09..bb94722 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftNotificationApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftNotificationApiTest.java @@ -1,32 +1,32 @@ -package gg.modl.backend.minecraft; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftNotificationApiTest { - - static ApiClient api; - - private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void acknowledgeNotifications() throws Exception { - var response = api.minecraftPost("/v1/minecraft/notifications/acknowledge", Map.of( - "playerUuid", TEST_UUID, - "notificationIds", List.of("nonexistent-id") - )); - JsonHelper.assertStatus(response, 200); - } -} +package gg.modl.backend.minecraft; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftNotificationApiTest { + + static ApiClient api; + + private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void acknowledgeNotifications() throws Exception { + var response = api.minecraftPost("/v1/minecraft/notifications/acknowledge", Map.of( + "playerUuid", TEST_UUID, + "notificationIds", List.of("nonexistent-id") + )); + JsonHelper.assertStatus(response, 200); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftPlayerApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftPlayerApiTest.java index ea4d0d0..404abcd 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftPlayerApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftPlayerApiTest.java @@ -1,195 +1,195 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDataProvider.PlayerInfo; -import gg.modl.backend.support.TestDatabase; -import java.util.List; -import java.util.Map; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftPlayerApiTest { - - static ApiClient api; - - private static String testUuid; - private static String testUsername; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - - PlayerInfo player = TestDataProvider.getPlayers().get(0); - testUuid = player.uuid(); - testUsername = player.username(); - } - - @Test - void login() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/login", Map.of( - "minecraftUUID", testUuid, - "username", testUsername, - "ip", "127.0.0.1", - "serverName", "lobby" - )); - // 201 for new player, 200 for returning player - int status = Integer.parseInt(JsonHelper.parseObject(response.body()).get("status").getAsString()); - assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("activePunishments")); - - // DB VERIFICATION: confirm player document updated - if (TestDatabase.isAvailable()) { - var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(testUuid); - assertNotNull(dbPlayer, "Player should exist in DB after login"); - // Username is stored in the usernames array, not a top-level field - var usernames = dbPlayer.getList("usernames", Document.class); - assertNotNull(usernames, "Usernames list should exist"); - assertTrue(usernames.stream().anyMatch(u -> - testUsername.equals(u.getString("username"))), - "Usernames should contain " + testUsername); - } - } - - @Test - void disconnect() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/disconnect", Map.of( - "minecraftUuid", testUuid, - "sessionDurationMs", 5000 - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void updateServer() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/update-server", Map.of( - "minecraftUuid", testUuid, - "serverName", "lobby" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getOnlinePlayers() throws Exception { - var response = api.minecraftGet("/v1/minecraft/players/online"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("players")); - } - - @Test - void getPlayerByUuid() throws Exception { - var response = api.minecraftGet("/v1/minecraft/players/" + testUuid); - JsonHelper.assertStatus(response, 200); - } - - @Test - void lookupPlayerByName() throws Exception { - var response = api.minecraftGet("/v1/minecraft/players/by-name?username=" + testUsername + "&queryMojang=false"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void lookupPost() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/lookup", Map.of( - "query", testUsername, - "shouldQueryMojang", false - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void addNote() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/" + testUuid + "/notes", Map.of( - "text", "API test note - safe to ignore", - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - - // DB VERIFICATION: confirm note exists in player document - if (TestDatabase.isAvailable()) { - var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(testUuid); - assertNotNull(dbPlayer, "Player should exist in DB"); - var notesList = dbPlayer.get("notes"); - assertNotNull(notesList, "Notes list should exist"); - assertTrue(notesList instanceof List, "Notes should be a list"); - @SuppressWarnings("unchecked") - var notes = (List) notesList; - assertTrue(notes.stream().anyMatch(n -> { - if (n instanceof Document doc) { - return "API test note - safe to ignore".equals(doc.getString("text")); - } - return false; - }), "Should contain the test note"); - } - } - - @Test - void getLinkedAccounts() throws Exception { - var response = api.minecraftGet("/v1/minecraft/players/" + testUuid + "/linked-accounts"); - Assumptions.assumeTrue( - response.statusCode() != 500, - "Minecraft linked-accounts endpoint is currently failing in the configured environment" - ); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("linkedAccounts")); - } - - @Test - void getPlayerReports() throws Exception { - var response = api.minecraftGet("/v1/minecraft/players/" + testUuid + "/reports"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("reports")); - } - - @Test - void submitIpInfo() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/submit-ip-info", Map.of( - "minecraftUUID", testUuid, - "ip", "127.0.0.1", - "country", "US", - "region", "CA", - "asn", "AS0", - "proxy", false, - "hosting", false - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void pardonByName() throws Exception { - // This may or may not find active punishments, both 200 outcomes are fine - var response = api.minecraftPost("/v1/minecraft/players/pardon", Map.of( - "playerName", testUsername, - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void rejectsWithoutApiKey() throws Exception { - var response = api.rawGet("/v1/minecraft/players/online"); - assertEquals(401, response.statusCode()); - } - - @Test - void lookupDifferentPlayers() throws Exception { - var players = TestDataProvider.getPlayers(); - for (var player : players) { - var response = api.minecraftGet("/v1/minecraft/players/" + player.uuid()); - JsonHelper.assertStatus(response, 200); - } - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDataProvider.PlayerInfo; +import gg.modl.backend.support.TestDatabase; +import java.util.List; +import java.util.Map; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftPlayerApiTest { + + static ApiClient api; + + private static String testUuid; + private static String testUsername; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + + PlayerInfo player = TestDataProvider.getPlayers().get(0); + testUuid = player.uuid(); + testUsername = player.username(); + } + + @Test + void login() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/login", Map.of( + "minecraftUUID", testUuid, + "username", testUsername, + "ip", "127.0.0.1", + "serverName", "lobby" + )); + // 201 for new player, 200 for returning player + int status = Integer.parseInt(JsonHelper.parseObject(response.body()).get("status").getAsString()); + assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("activePunishments")); + + // DB VERIFICATION: confirm player document updated + if (TestDatabase.isAvailable()) { + var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(testUuid); + assertNotNull(dbPlayer, "Player should exist in DB after login"); + // Username is stored in the usernames array, not a top-level field + var usernames = dbPlayer.getList("usernames", Document.class); + assertNotNull(usernames, "Usernames list should exist"); + assertTrue(usernames.stream().anyMatch(u -> + testUsername.equals(u.getString("username"))), + "Usernames should contain " + testUsername); + } + } + + @Test + void disconnect() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/disconnect", Map.of( + "minecraftUuid", testUuid, + "sessionDurationMs", 5000 + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void updateServer() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/update-server", Map.of( + "minecraftUuid", testUuid, + "serverName", "lobby" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getOnlinePlayers() throws Exception { + var response = api.minecraftGet("/v1/minecraft/players/online"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("players")); + } + + @Test + void getPlayerByUuid() throws Exception { + var response = api.minecraftGet("/v1/minecraft/players/" + testUuid); + JsonHelper.assertStatus(response, 200); + } + + @Test + void lookupPlayerByName() throws Exception { + var response = api.minecraftGet("/v1/minecraft/players/by-name?username=" + testUsername + "&queryMojang=false"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void lookupPost() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/lookup", Map.of( + "query", testUsername, + "shouldQueryMojang", false + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void addNote() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/" + testUuid + "/notes", Map.of( + "text", "API test note - safe to ignore", + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + + // DB VERIFICATION: confirm note exists in player document + if (TestDatabase.isAvailable()) { + var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(testUuid); + assertNotNull(dbPlayer, "Player should exist in DB"); + var notesList = dbPlayer.get("notes"); + assertNotNull(notesList, "Notes list should exist"); + assertTrue(notesList instanceof List, "Notes should be a list"); + @SuppressWarnings("unchecked") + var notes = (List) notesList; + assertTrue(notes.stream().anyMatch(n -> { + if (n instanceof Document doc) { + return "API test note - safe to ignore".equals(doc.getString("text")); + } + return false; + }), "Should contain the test note"); + } + } + + @Test + void getLinkedAccounts() throws Exception { + var response = api.minecraftGet("/v1/minecraft/players/" + testUuid + "/linked-accounts"); + Assumptions.assumeTrue( + response.statusCode() != 500, + "Minecraft linked-accounts endpoint is currently failing in the configured environment" + ); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("linkedAccounts")); + } + + @Test + void getPlayerReports() throws Exception { + var response = api.minecraftGet("/v1/minecraft/players/" + testUuid + "/reports"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("reports")); + } + + @Test + void submitIpInfo() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/submit-ip-info", Map.of( + "minecraftUUID", testUuid, + "ip", "127.0.0.1", + "country", "US", + "region", "CA", + "asn", "AS0", + "proxy", false, + "hosting", false + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void pardonByName() throws Exception { + // This may or may not find active punishments, both 200 outcomes are fine + var response = api.minecraftPost("/v1/minecraft/players/pardon", Map.of( + "playerName", testUsername, + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void rejectsWithoutApiKey() throws Exception { + var response = api.rawGet("/v1/minecraft/players/online"); + assertEquals(401, response.statusCode()); + } + + @Test + void lookupDifferentPlayers() throws Exception { + var players = TestDataProvider.getPlayers(); + for (var player : players) { + var response = api.minecraftGet("/v1/minecraft/players/" + player.uuid()); + JsonHelper.assertStatus(response, 200); + } + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentApiTest.java index a4e0a35..04f5458 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentApiTest.java @@ -1,413 +1,413 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDataProvider.PlayerInfo; -import gg.modl.backend.support.TestDatabase; -import java.util.Map; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftPunishmentApiTest { - - static ApiClient api; - - private static String testUuid; - private static String testUsername; - private static int testTypeOrdinal; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - - PlayerInfo player = TestDataProvider.getPlayerWithPunishments(); - testUuid = player.uuid(); - testUsername = player.username(); - testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); - } - - @Test - void createAndPardonPunishment() throws Exception { - // Create - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - auto cleanup", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - var createJson = JsonHelper.parseObject(createResponse.body()); - assertTrue(createJson.has("punishmentId"), "Response should contain punishmentId"); - String punishmentId = createJson.get("punishmentId").getAsString(); - - // DB VERIFICATION: confirm punishment exists in MongoDB - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB after creation"); - assertEquals("TestBot", dbPunishment.getString("issuerName")); - } - - // Cleanup: pardon it - var pardonResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - JsonHelper.assertStatus(pardonResponse, 200); - - // DB VERIFICATION: confirm pardon modification exists - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should still exist in DB after pardon"); - var mods = dbPunishment.getList("modifications", Document.class); - assertNotNull(mods, "Modifications list should exist"); - assertTrue(mods.stream().anyMatch(m -> { - String type = m.getString("type"); - return "PARDON".equals(type) || "MANUAL_PARDON".equals(type); - }), "Should have a PARDON modification"); - } - } - - @Test - void createWithLegacyEndpoint() throws Exception { - var response = api.minecraftPost("/v1/minecraft/punishments/create", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test legacy - auto cleanup", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(response, 200); - - // Pardon via player pardon endpoint to clean up - api.minecraftPost("/v1/minecraft/players/pardon", Map.of( - "playerName", testUsername, - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void getPunishmentById() throws Exception { - // Create one first - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - get by id", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - // Get by ID - var getResponse = api.minecraftGet("/v1/minecraft/punishments/" + punishmentId); - JsonHelper.assertStatus(getResponse, 200); - var json = JsonHelper.parseObject(getResponse.body()); - assertTrue(json.has("punishment")); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void getUploadToken() throws Exception { - // Create punishment - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - upload token", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var tokenResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/upload-token", Map.of( - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(tokenResponse, 200); - var json = JsonHelper.parseObject(tokenResponse.body()); - assertTrue(json.has("token")); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void getRecentPunishments() throws Exception { - var response = api.minecraftGet("/v1/minecraft/punishments/recent?hours=48"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("punishments")); - } - - @Test - void previewPunishment() throws Exception { - var response = api.minecraftGet("/v1/minecraft/punishments/preview?playerUuid=" + testUuid + "&typeOrdinal=" + testTypeOrdinal); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("success")); - } - - @Test - void acknowledgePunishment() throws Exception { - // Create punishment - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - acknowledge", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var ackResponse = api.minecraftPost("/v1/minecraft/punishments/acknowledge", Map.of( - "punishmentId", punishmentId, - "playerUuid", testUuid, - "executedAt", "2025-01-01T00:00:00Z", - "success", true - )); - JsonHelper.assertStatus(ackResponse, 200); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void addNoteToPunishment() throws Exception { - // Create punishment - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - add note", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var noteResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/note", Map.of( - "issuerName", "TestBot", - "note", "API test note" - )); - JsonHelper.assertStatus(noteResponse, 200); - - // DB VERIFICATION: confirm note exists - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB"); - var notes = dbPunishment.getList("notes", Document.class); - assertNotNull(notes, "Notes list should exist"); - assertTrue(notes.stream().anyMatch(n -> "API test note".equals(n.getString("note")) - || "API test note".equals(n.getString("text"))), - "Should contain the test note"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void addEvidenceToPunishment() throws Exception { - // Create punishment - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - add evidence", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var evidenceResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/evidence", Map.of( - "issuerName", "TestBot", - "evidenceUrl", "https://example.com/evidence.png" - )); - JsonHelper.assertStatus(evidenceResponse, 200); - - // DB VERIFICATION: confirm evidence exists - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB"); - var evidence = dbPunishment.getList("evidence", Document.class); - assertNotNull(evidence, "Evidence list should exist"); - assertTrue(evidence.stream().anyMatch(e -> - "https://example.com/evidence.png".equals(e.getString("url")) - || "https://example.com/evidence.png".equals(e.getString("evidenceUrl"))), - "Should contain the test evidence URL"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void updatePunishmentDuration() throws Exception { - // Create punishment - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - duration update", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var durationResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/duration", Map.of( - "issuerName", "TestBot", - "newDuration", 120 - )); - JsonHelper.assertStatus(durationResponse, 200); - - // DB VERIFICATION: confirm duration change modification - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB"); - var mods = dbPunishment.getList("modifications", Document.class); - assertNotNull(mods, "Modifications list should exist"); - assertTrue(mods.stream().anyMatch(m -> { - String type = m.getString("type"); - return "DURATION_CHANGE".equals(type) || "MANUAL_DURATION_CHANGE".equals(type); - }), "Should have a DURATION_CHANGE modification"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void togglePunishmentOption() throws Exception { - // Create punishment - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - toggle option", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var toggleResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/toggle", Map.of( - "issuerName", "TestBot", - "option", "ALT_BLOCKING", - "enabled", true - )); - JsonHelper.assertStatus(toggleResponse, 200); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void createPunishmentWithEachType() throws Exception { - var types = TestDataProvider.getPunishmentTypes(); - for (var type : types) { - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", type.ordinal(), - "reason", "API Test - type " + type.name(), - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - // DB VERIFICATION - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment with type " + type.name() + " should exist in DB"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - } - - @Test - void createPunishmentForDifferentPlayers() throws Exception { - var players = TestDataProvider.getPlayers(); - for (var player : players) { - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", player.uuid(), - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - player " + player.username(), - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - // DB VERIFICATION - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(player.uuid(), punishmentId); - assertNotNull(dbPunishment, "Punishment for " + player.username() + " should exist in DB"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDataProvider.PlayerInfo; +import gg.modl.backend.support.TestDatabase; +import java.util.Map; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftPunishmentApiTest { + + static ApiClient api; + + private static String testUuid; + private static String testUsername; + private static int testTypeOrdinal; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + + PlayerInfo player = TestDataProvider.getPlayerWithPunishments(); + testUuid = player.uuid(); + testUsername = player.username(); + testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); + } + + @Test + void createAndPardonPunishment() throws Exception { + // Create + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - auto cleanup", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + var createJson = JsonHelper.parseObject(createResponse.body()); + assertTrue(createJson.has("punishmentId"), "Response should contain punishmentId"); + String punishmentId = createJson.get("punishmentId").getAsString(); + + // DB VERIFICATION: confirm punishment exists in MongoDB + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB after creation"); + assertEquals("TestBot", dbPunishment.getString("issuerName")); + } + + // Cleanup: pardon it + var pardonResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + JsonHelper.assertStatus(pardonResponse, 200); + + // DB VERIFICATION: confirm pardon modification exists + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should still exist in DB after pardon"); + var mods = dbPunishment.getList("modifications", Document.class); + assertNotNull(mods, "Modifications list should exist"); + assertTrue(mods.stream().anyMatch(m -> { + String type = m.getString("type"); + return "PARDON".equals(type) || "MANUAL_PARDON".equals(type); + }), "Should have a PARDON modification"); + } + } + + @Test + void createWithLegacyEndpoint() throws Exception { + var response = api.minecraftPost("/v1/minecraft/punishments/create", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test legacy - auto cleanup", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(response, 200); + + // Pardon via player pardon endpoint to clean up + api.minecraftPost("/v1/minecraft/players/pardon", Map.of( + "playerName", testUsername, + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void getPunishmentById() throws Exception { + // Create one first + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - get by id", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + // Get by ID + var getResponse = api.minecraftGet("/v1/minecraft/punishments/" + punishmentId); + JsonHelper.assertStatus(getResponse, 200); + var json = JsonHelper.parseObject(getResponse.body()); + assertTrue(json.has("punishment")); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void getUploadToken() throws Exception { + // Create punishment + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - upload token", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var tokenResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/upload-token", Map.of( + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(tokenResponse, 200); + var json = JsonHelper.parseObject(tokenResponse.body()); + assertTrue(json.has("token")); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void getRecentPunishments() throws Exception { + var response = api.minecraftGet("/v1/minecraft/punishments/recent?hours=48"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("punishments")); + } + + @Test + void previewPunishment() throws Exception { + var response = api.minecraftGet("/v1/minecraft/punishments/preview?playerUuid=" + testUuid + "&typeOrdinal=" + testTypeOrdinal); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("success")); + } + + @Test + void acknowledgePunishment() throws Exception { + // Create punishment + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - acknowledge", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var ackResponse = api.minecraftPost("/v1/minecraft/punishments/acknowledge", Map.of( + "punishmentId", punishmentId, + "playerUuid", testUuid, + "executedAt", "2025-01-01T00:00:00Z", + "success", true + )); + JsonHelper.assertStatus(ackResponse, 200); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void addNoteToPunishment() throws Exception { + // Create punishment + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - add note", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var noteResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/note", Map.of( + "issuerName", "TestBot", + "note", "API test note" + )); + JsonHelper.assertStatus(noteResponse, 200); + + // DB VERIFICATION: confirm note exists + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB"); + var notes = dbPunishment.getList("notes", Document.class); + assertNotNull(notes, "Notes list should exist"); + assertTrue(notes.stream().anyMatch(n -> "API test note".equals(n.getString("note")) + || "API test note".equals(n.getString("text"))), + "Should contain the test note"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void addEvidenceToPunishment() throws Exception { + // Create punishment + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - add evidence", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var evidenceResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/evidence", Map.of( + "issuerName", "TestBot", + "evidenceUrl", "https://example.com/evidence.png" + )); + JsonHelper.assertStatus(evidenceResponse, 200); + + // DB VERIFICATION: confirm evidence exists + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB"); + var evidence = dbPunishment.getList("evidence", Document.class); + assertNotNull(evidence, "Evidence list should exist"); + assertTrue(evidence.stream().anyMatch(e -> + "https://example.com/evidence.png".equals(e.getString("url")) + || "https://example.com/evidence.png".equals(e.getString("evidenceUrl"))), + "Should contain the test evidence URL"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void updatePunishmentDuration() throws Exception { + // Create punishment + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - duration update", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var durationResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/duration", Map.of( + "issuerName", "TestBot", + "newDuration", 120 + )); + JsonHelper.assertStatus(durationResponse, 200); + + // DB VERIFICATION: confirm duration change modification + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB"); + var mods = dbPunishment.getList("modifications", Document.class); + assertNotNull(mods, "Modifications list should exist"); + assertTrue(mods.stream().anyMatch(m -> { + String type = m.getString("type"); + return "DURATION_CHANGE".equals(type) || "MANUAL_DURATION_CHANGE".equals(type); + }), "Should have a DURATION_CHANGE modification"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void togglePunishmentOption() throws Exception { + // Create punishment + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - toggle option", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var toggleResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/toggle", Map.of( + "issuerName", "TestBot", + "option", "ALT_BLOCKING", + "enabled", true + )); + JsonHelper.assertStatus(toggleResponse, 200); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void createPunishmentWithEachType() throws Exception { + var types = TestDataProvider.getPunishmentTypes(); + for (var type : types) { + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", type.ordinal(), + "reason", "API Test - type " + type.name(), + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + // DB VERIFICATION + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment with type " + type.name() + " should exist in DB"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + } + + @Test + void createPunishmentForDifferentPlayers() throws Exception { + var players = TestDataProvider.getPlayers(); + for (var player : players) { + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", player.uuid(), + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - player " + player.username(), + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + // DB VERIFICATION + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(player.uuid(), punishmentId); + assertNotNull(dbPunishment, "Punishment for " + player.username() + " should exist in DB"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentTypeApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentTypeApiTest.java index 6030979..c2ae9c0 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentTypeApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftPunishmentTypeApiTest.java @@ -1,29 +1,29 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftPunishmentTypeApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void getPunishmentTypes() throws Exception { - var response = api.minecraftGet("/v1/minecraft/punishments/types"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("data")); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftPunishmentTypeApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void getPunishmentTypes() throws Exception { + var response = api.minecraftGet("/v1/minecraft/punishments/types"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("data")); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftReportApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftReportApiTest.java index 9f47e4e..ced5a52 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftReportApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftReportApiTest.java @@ -1,94 +1,94 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftReportApiTest { - - static ApiClient api; - - private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void listOpenReports() throws Exception { - var response = api.minecraftGet("/v1/minecraft/reports?status=open&limit=10"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("reports")); - } - - @Test - void listPlayerReports() throws Exception { - var response = api.minecraftGet("/v1/minecraft/reports/player/" + TEST_UUID + "?status=all&limit=5"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("reports")); - } - - @Test - void dismissReport() throws Exception { - var listResponse = api.minecraftGet("/v1/minecraft/reports?status=open&limit=1"); - var json = JsonHelper.parseObject(listResponse.body()); - var reports = json.getAsJsonArray("reports"); - if (reports.isEmpty()) { - return; - } - - String reportId = reports.get(0).getAsJsonObject().get("id").getAsString(); - - var response = api.minecraftPost("/v1/minecraft/reports/" + reportId + "/dismiss", Map.of( - "dismissedBy", "TestBot", - "reason", "API test - dismissed for testing" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void assignReport() throws Exception { - var listResponse = api.minecraftGet("/v1/minecraft/reports?status=open&limit=1"); - var json = JsonHelper.parseObject(listResponse.body()); - var reports = json.getAsJsonArray("reports"); - if (reports.isEmpty()) { - return; - } - - String reportId = reports.get(0).getAsJsonObject().get("id").getAsString(); - - var response = api.minecraftPost("/v1/minecraft/reports/" + reportId + "/assign", Map.of( - "assignee", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void resolveReport() throws Exception { - var listResponse = api.minecraftGet("/v1/minecraft/reports?status=open&limit=1"); - var json = JsonHelper.parseObject(listResponse.body()); - var reports = json.getAsJsonArray("reports"); - if (reports.isEmpty()) { - return; - } - - String reportId = reports.get(0).getAsJsonObject().get("id").getAsString(); - - var response = api.minecraftPost("/v1/minecraft/reports/" + reportId + "/resolve", Map.of( - "resolvedBy", "TestBot", - "resolution", "API test - resolved for testing", - "punishmentId", "test-punishment-id" - )); - JsonHelper.assertStatus(response, 200); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftReportApiTest { + + static ApiClient api; + + private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void listOpenReports() throws Exception { + var response = api.minecraftGet("/v1/minecraft/reports?status=open&limit=10"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("reports")); + } + + @Test + void listPlayerReports() throws Exception { + var response = api.minecraftGet("/v1/minecraft/reports/player/" + TEST_UUID + "?status=all&limit=5"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("reports")); + } + + @Test + void dismissReport() throws Exception { + var listResponse = api.minecraftGet("/v1/minecraft/reports?status=open&limit=1"); + var json = JsonHelper.parseObject(listResponse.body()); + var reports = json.getAsJsonArray("reports"); + if (reports.isEmpty()) { + return; + } + + String reportId = reports.get(0).getAsJsonObject().get("id").getAsString(); + + var response = api.minecraftPost("/v1/minecraft/reports/" + reportId + "/dismiss", Map.of( + "dismissedBy", "TestBot", + "reason", "API test - dismissed for testing" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void assignReport() throws Exception { + var listResponse = api.minecraftGet("/v1/minecraft/reports?status=open&limit=1"); + var json = JsonHelper.parseObject(listResponse.body()); + var reports = json.getAsJsonArray("reports"); + if (reports.isEmpty()) { + return; + } + + String reportId = reports.get(0).getAsJsonObject().get("id").getAsString(); + + var response = api.minecraftPost("/v1/minecraft/reports/" + reportId + "/assign", Map.of( + "assignee", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void resolveReport() throws Exception { + var listResponse = api.minecraftGet("/v1/minecraft/reports?status=open&limit=1"); + var json = JsonHelper.parseObject(listResponse.body()); + var reports = json.getAsJsonArray("reports"); + if (reports.isEmpty()) { + return; + } + + String reportId = reports.get(0).getAsJsonObject().get("id").getAsString(); + + var response = api.minecraftPost("/v1/minecraft/reports/" + reportId + "/resolve", Map.of( + "resolvedBy", "TestBot", + "resolution", "API test - resolved for testing", + "punishmentId", "test-punishment-id" + )); + JsonHelper.assertStatus(response, 200); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftRoleApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftRoleApiTest.java index 3d2f89b..492b86b 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftRoleApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftRoleApiTest.java @@ -1,84 +1,84 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftRoleApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void listRoles() throws Exception { - var response = api.minecraftGet("/v1/minecraft/roles"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("roles")); - } - - @Test - void getRoleById() throws Exception { - // Get a role ID from list - var listResponse = api.minecraftGet("/v1/minecraft/roles"); - var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); - if (roles.isEmpty()) { - return; - } - - String roleId = roles.get(0).getAsJsonObject().get("id").getAsString(); - var response = api.minecraftGet("/v1/minecraft/roles/" + roleId); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("role")); - } - - @Test - void updateRolePermissions() throws Exception { - // Get a role to update (idempotent - set same permissions). - var listResponse = api.minecraftGet("/v1/minecraft/roles"); - var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); - if (roles.isEmpty()) { - return; - } - - // The super-admin role is protected (updateRolePermissions now 403s for it), so pick the - // last non-super-admin role instead; skip if every role is a protected super-admin role. - com.google.gson.JsonObject role = null; - for (int i = roles.size() - 1; i >= 0; i--) { - var candidate = roles.get(i).getAsJsonObject(); - String candidateId = candidate.get("id").getAsString(); - if (candidateId != null && !candidateId.contains("super-admin")) { - role = candidate; - break; - } - } - if (role == null) { - return; - } - - String roleId = role.get("id").getAsString(); - var permissions = role.getAsJsonArray("permissions"); - - // Convert to List - List permList = new java.util.ArrayList<>(); - permissions.forEach(p -> permList.add(p.getAsString())); - - var response = api.minecraftPatch("/v1/minecraft/roles/" + roleId + "/permissions", Map.of( - "permissions", permList - )); - JsonHelper.assertStatus(response, 200); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftRoleApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void listRoles() throws Exception { + var response = api.minecraftGet("/v1/minecraft/roles"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("roles")); + } + + @Test + void getRoleById() throws Exception { + // Get a role ID from list + var listResponse = api.minecraftGet("/v1/minecraft/roles"); + var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); + if (roles.isEmpty()) { + return; + } + + String roleId = roles.get(0).getAsJsonObject().get("id").getAsString(); + var response = api.minecraftGet("/v1/minecraft/roles/" + roleId); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("role")); + } + + @Test + void updateRolePermissions() throws Exception { + // Get a role to update (idempotent - set same permissions). + var listResponse = api.minecraftGet("/v1/minecraft/roles"); + var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); + if (roles.isEmpty()) { + return; + } + + // The super-admin role is protected (updateRolePermissions now 403s for it), so pick the + // last non-super-admin role instead; skip if every role is a protected super-admin role. + com.google.gson.JsonObject role = null; + for (int i = roles.size() - 1; i >= 0; i--) { + var candidate = roles.get(i).getAsJsonObject(); + String candidateId = candidate.get("id").getAsString(); + if (candidateId != null && !candidateId.contains("super-admin")) { + role = candidate; + break; + } + } + if (role == null) { + return; + } + + String roleId = role.get("id").getAsString(); + var permissions = role.getAsJsonArray("permissions"); + + // Convert to List + List permList = new java.util.ArrayList<>(); + permissions.forEach(p -> permList.add(p.getAsString())); + + var response = api.minecraftPatch("/v1/minecraft/roles/" + roleId + "/permissions", Map.of( + "permissions", permList + )); + JsonHelper.assertStatus(response, 200); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftStaffApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftStaffApiTest.java index 7f4a02e..dcabd2b 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftStaffApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftStaffApiTest.java @@ -1,58 +1,58 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftStaffApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void listStaff() throws Exception { - var response = api.minecraftGet("/v1/minecraft/staff"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("staff")); - } - - @Test - void getPermissions() throws Exception { - var response = api.minecraftGet("/v1/minecraft/staff/permissions"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("data")); - } - - @Test - void updateStaffRole() throws Exception { - // Get a staff member to update - var listResponse = api.minecraftGet("/v1/minecraft/staff"); - var json = JsonHelper.parseObject(listResponse.body()); - var staff = json.getAsJsonArray("staff"); - if (staff.isEmpty()) { - return; - } - - // Just read; don't actually mutate roles in staging - String staffId = staff.get(0).getAsJsonObject().get("id").getAsString(); - String currentRole = staff.get(0).getAsJsonObject().get("role").getAsString(); - - // Set to same role (idempotent) - var response = api.minecraftPatch("/v1/minecraft/staff/" + staffId + "/role", java.util.Map.of( - "role", currentRole - )); - JsonHelper.assertStatus(response, 200); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftStaffApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void listStaff() throws Exception { + var response = api.minecraftGet("/v1/minecraft/staff"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("staff")); + } + + @Test + void getPermissions() throws Exception { + var response = api.minecraftGet("/v1/minecraft/staff/permissions"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("data")); + } + + @Test + void updateStaffRole() throws Exception { + // Get a staff member to update + var listResponse = api.minecraftGet("/v1/minecraft/staff"); + var json = JsonHelper.parseObject(listResponse.body()); + var staff = json.getAsJsonArray("staff"); + if (staff.isEmpty()) { + return; + } + + // Just read; don't actually mutate roles in staging + String staffId = staff.get(0).getAsJsonObject().get("id").getAsString(); + String currentRole = staff.get(0).getAsJsonObject().get("role").getAsString(); + + // Set to same role (idempotent) + var response = api.minecraftPatch("/v1/minecraft/staff/" + staffId + "/role", java.util.Map.of( + "role", currentRole + )); + JsonHelper.assertStatus(response, 200); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftStatWipeApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftStatWipeApiTest.java index efb00a3..cd9a706 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftStatWipeApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftStatWipeApiTest.java @@ -1,189 +1,189 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDataProvider.PlayerInfo; -import gg.modl.backend.support.TestDatabase; -import java.util.List; -import java.util.Map; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftStatWipeApiTest { - - static ApiClient api; - private static String testUuid; - private static int testTypeOrdinal; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - - PlayerInfo player = TestDataProvider.getPlayerWithPunishments(); - testUuid = player.uuid(); - testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); - } - - @Test - void statWipeAcknowledgeSetsCompletedFlag() throws Exception { - // Create a punishment with wipeAfterExpiry enabled - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - stat wipe acknowledge", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE", - "data", Map.of("wipeAfterExpiry", true) - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - // Acknowledge the stat wipe - var ackResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/stat-wipe-acknowledge", Map.of( - "punishmentId", punishmentId, - "serverName", "TestServer", - "success", true - )); - JsonHelper.assertStatus(ackResponse, 200); - var ackJson = JsonHelper.parseObject(ackResponse.body()); - assertTrue(ackJson.has("success")); - - // DB VERIFICATION: confirm statWipeCompleted is set - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB"); - var data = dbPunishment.get("data", Document.class); - assertNotNull(data, "Punishment data should exist"); - assertTrue(data.getBoolean("statWipeCompleted", false), - "statWipeCompleted should be true after acknowledgement"); - assertNotNull(data.get("statWipeCompletedAt"), - "statWipeCompletedAt should be set"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void statWipeAcknowledgeReturnsNotFoundForInvalidId() throws Exception { - var ackResponse = api.minecraftPost("/v1/minecraft/punishments/nonexistent-id/stat-wipe-acknowledge", Map.of( - "punishmentId", "nonexistent-id", - "serverName", "TestServer", - "success", true - )); - JsonHelper.assertStatus(ackResponse, 404); - } - - @Test - void statWipeAcknowledgeIgnoresWhenWipeDisabled() throws Exception { - // Create punishment WITHOUT wipeAfterExpiry - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - stat wipe disabled", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - // Acknowledge should succeed but not set the flag since wipeAfterExpiry is not enabled - var ackResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/stat-wipe-acknowledge", Map.of( - "punishmentId", punishmentId, - "serverName", "TestServer", - "success", true - )); - JsonHelper.assertStatus(ackResponse, 200); - var ackJson = JsonHelper.parseObject(ackResponse.body()); - assertEquals("Stat wipe no longer enabled for this punishment", ackJson.get("message").getAsString()); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } - - @Test - void syncResponseIncludesPendingStatWipesField() throws Exception { - // Verify the sync response now includes the pendingStatWipes field - var response = api.minecraftPost("/v1/minecraft/players/sync", Map.of( - "lastSyncTimestamp", "2025-01-01T00:00:00Z", - "onlinePlayers", List.of(), - "serverStatus", Map.of( - "onlinePlayerCount", 0, - "maxPlayers", 100, - "serverVersion", "1.21", - "timestamp", System.currentTimeMillis() - ) - )); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("data")); - var data = json.getAsJsonObject("data"); - assertTrue(data.has("pendingStatWipes"), "Sync response data should include pendingStatWipes field"); - } - - @Test - void toggleStatWipeThenAcknowledge() throws Exception { - // Create a punishment without wipeAfterExpiry - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "API Test - toggle then ack", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - JsonHelper.assertStatus(createResponse, 200); - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - // Enable stat wipe via toggle - var toggleResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/toggle", Map.of( - "issuerName", "TestBot", - "option", "STAT_WIPE", - "enabled", true - )); - JsonHelper.assertStatus(toggleResponse, 200); - - // Now acknowledge should work and set the flag - var ackResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/stat-wipe-acknowledge", Map.of( - "punishmentId", punishmentId, - "serverName", "TestServer", - "success", true - )); - JsonHelper.assertStatus(ackResponse, 200); - - // DB VERIFICATION - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist"); - var data = dbPunishment.get("data", Document.class); - assertTrue(data.getBoolean("wipeAfterExpiry", false), "wipeAfterExpiry should be true"); - assertTrue(data.getBoolean("statWipeCompleted", false), "statWipeCompleted should be true"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "API test cleanup" - )); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDataProvider.PlayerInfo; +import gg.modl.backend.support.TestDatabase; +import java.util.List; +import java.util.Map; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftStatWipeApiTest { + + static ApiClient api; + private static String testUuid; + private static int testTypeOrdinal; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + + PlayerInfo player = TestDataProvider.getPlayerWithPunishments(); + testUuid = player.uuid(); + testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); + } + + @Test + void statWipeAcknowledgeSetsCompletedFlag() throws Exception { + // Create a punishment with wipeAfterExpiry enabled + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - stat wipe acknowledge", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE", + "data", Map.of("wipeAfterExpiry", true) + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + // Acknowledge the stat wipe + var ackResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/stat-wipe-acknowledge", Map.of( + "punishmentId", punishmentId, + "serverName", "TestServer", + "success", true + )); + JsonHelper.assertStatus(ackResponse, 200); + var ackJson = JsonHelper.parseObject(ackResponse.body()); + assertTrue(ackJson.has("success")); + + // DB VERIFICATION: confirm statWipeCompleted is set + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB"); + var data = dbPunishment.get("data", Document.class); + assertNotNull(data, "Punishment data should exist"); + assertTrue(data.getBoolean("statWipeCompleted", false), + "statWipeCompleted should be true after acknowledgement"); + assertNotNull(data.get("statWipeCompletedAt"), + "statWipeCompletedAt should be set"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void statWipeAcknowledgeReturnsNotFoundForInvalidId() throws Exception { + var ackResponse = api.minecraftPost("/v1/minecraft/punishments/nonexistent-id/stat-wipe-acknowledge", Map.of( + "punishmentId", "nonexistent-id", + "serverName", "TestServer", + "success", true + )); + JsonHelper.assertStatus(ackResponse, 404); + } + + @Test + void statWipeAcknowledgeIgnoresWhenWipeDisabled() throws Exception { + // Create punishment WITHOUT wipeAfterExpiry + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - stat wipe disabled", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + // Acknowledge should succeed but not set the flag since wipeAfterExpiry is not enabled + var ackResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/stat-wipe-acknowledge", Map.of( + "punishmentId", punishmentId, + "serverName", "TestServer", + "success", true + )); + JsonHelper.assertStatus(ackResponse, 200); + var ackJson = JsonHelper.parseObject(ackResponse.body()); + assertEquals("Stat wipe no longer enabled for this punishment", ackJson.get("message").getAsString()); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } + + @Test + void syncResponseIncludesPendingStatWipesField() throws Exception { + // Verify the sync response now includes the pendingStatWipes field + var response = api.minecraftPost("/v1/minecraft/players/sync", Map.of( + "lastSyncTimestamp", "2025-01-01T00:00:00Z", + "onlinePlayers", List.of(), + "serverStatus", Map.of( + "onlinePlayerCount", 0, + "maxPlayers", 100, + "serverVersion", "1.21", + "timestamp", System.currentTimeMillis() + ) + )); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("data")); + var data = json.getAsJsonObject("data"); + assertTrue(data.has("pendingStatWipes"), "Sync response data should include pendingStatWipes field"); + } + + @Test + void toggleStatWipeThenAcknowledge() throws Exception { + // Create a punishment without wipeAfterExpiry + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "API Test - toggle then ack", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + JsonHelper.assertStatus(createResponse, 200); + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + // Enable stat wipe via toggle + var toggleResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/toggle", Map.of( + "issuerName", "TestBot", + "option", "STAT_WIPE", + "enabled", true + )); + JsonHelper.assertStatus(toggleResponse, 200); + + // Now acknowledge should work and set the flag + var ackResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/stat-wipe-acknowledge", Map.of( + "punishmentId", punishmentId, + "serverName", "TestServer", + "success", true + )); + JsonHelper.assertStatus(ackResponse, 200); + + // DB VERIFICATION + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist"); + var data = dbPunishment.get("data", Document.class); + assertTrue(data.getBoolean("wipeAfterExpiry", false), "wipeAfterExpiry should be true"); + assertTrue(data.getBoolean("statWipeCompleted", false), "statWipeCompleted should be true"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "API test cleanup" + )); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftSyncApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftSyncApiTest.java index 36afafa..65ba756 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftSyncApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftSyncApiTest.java @@ -1,41 +1,41 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftSyncApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - } - - @Test - void sync() throws Exception { - var response = api.minecraftPost("/v1/minecraft/players/sync", Map.of( - "lastSyncTimestamp", "2025-01-01T00:00:00Z", - "onlinePlayers", List.of(), - "serverStatus", Map.of( - "onlinePlayerCount", 0, - "maxPlayers", 100, - "serverVersion", "1.21", - "timestamp", System.currentTimeMillis() - ) - )); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("timestamp")); - assertTrue(json.has("data")); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftSyncApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + } + + @Test + void sync() throws Exception { + var response = api.minecraftPost("/v1/minecraft/players/sync", Map.of( + "lastSyncTimestamp", "2025-01-01T00:00:00Z", + "onlinePlayers", List.of(), + "serverStatus", Map.of( + "onlinePlayerCount", 0, + "maxPlayers", 100, + "serverVersion", "1.21", + "timestamp", System.currentTimeMillis() + ) + )); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("timestamp")); + assertTrue(json.has("data")); + } +} diff --git a/src/test/java/gg/modl/backend/minecraft/MinecraftTicketApiTest.java b/src/test/java/gg/modl/backend/minecraft/MinecraftTicketApiTest.java index d3f5570..26582d1 100644 --- a/src/test/java/gg/modl/backend/minecraft/MinecraftTicketApiTest.java +++ b/src/test/java/gg/modl/backend/minecraft/MinecraftTicketApiTest.java @@ -1,134 +1,134 @@ -package gg.modl.backend.minecraft; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDataProvider.PlayerInfo; -import gg.modl.backend.support.TestDatabase; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class MinecraftTicketApiTest { - - static ApiClient api; - - private static String testUuid; - private static String testUsername; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); - api = new ApiClient(); - - PlayerInfo player = TestDataProvider.getPlayers().get(0); - testUuid = player.uuid(); - testUsername = player.username(); - } - - @Test - void createAndCleanupTicket() throws Exception { - var createResponse = api.minecraftPost("/v1/minecraft/tickets", Map.of( - "creatorUuid", testUuid, - "creatorName", testUsername, - "type", "bug_report", - "subject", "API Test Ticket - auto cleanup", - "description", "This ticket was created by an automated API test." - )); - JsonHelper.assertStatus(createResponse, 200); - var json = JsonHelper.parseObject(createResponse.body()); - assertTrue(json.has("ticketId")); - String ticketId = json.get("ticketId").getAsString(); - - // DB VERIFICATION: confirm ticket exists in MongoDB - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB after creation"); - assertEquals("bug", dbTicket.getString("type")); - } - - // Cleanup: close via panel - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of( - "status", "closed" - )); - } - - @Test - void createUnfinishedTicket() throws Exception { - var response = api.minecraftPost("/v1/minecraft/tickets/unfinished", Map.of( - "creatorUuid", testUuid, - "creatorName", testUsername, - "type", "bug_report" - )); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("ticketId")); - - // Cleanup - String ticketId = json.get("ticketId").getAsString(); - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void listTickets() throws Exception { - var response = api.minecraftGet("/v1/minecraft/tickets?limit=5"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("tickets")); - } - - @Test - void getTicketById() throws Exception { - var createResponse = api.minecraftPost("/v1/minecraft/tickets", Map.of( - "creatorUuid", testUuid, - "creatorName", testUsername, - "type", "bug_report", - "subject", "API Test - get by id" - )); - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var response = api.minecraftGet("/v1/minecraft/tickets/" + ticketId); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("ticket")); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void getTicketsByPlayer() throws Exception { - var response = api.minecraftGet("/v1/minecraft/tickets/player/" + testUuid); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("tickets")); - } - - @Test - void claimTicket() throws Exception { - var createResponse = api.minecraftPost("/v1/minecraft/tickets", Map.of( - "creatorUuid", testUuid, - "creatorName", testUsername, - "type", "bug_report", - "subject", "API Test - claim" - )); - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var claimResponse = api.minecraftPost("/v1/minecraft/tickets/" + ticketId + "/claim", Map.of( - "playerUuid", testUuid, - "playerName", testUsername - )); - int claimStatus = claimResponse.statusCode(); - // 200 if claimed, 409 if ticket already linked to a player via creation - assertTrue(claimStatus == 200 || claimStatus == 409, "Expected 200 or 409 but got " + claimStatus); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } -} +package gg.modl.backend.minecraft; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDataProvider.PlayerInfo; +import gg.modl.backend.support.TestDatabase; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class MinecraftTicketApiTest { + + static ApiClient api; + + private static String testUuid; + private static String testUsername; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging credentials not configured"); + api = new ApiClient(); + + PlayerInfo player = TestDataProvider.getPlayers().get(0); + testUuid = player.uuid(); + testUsername = player.username(); + } + + @Test + void createAndCleanupTicket() throws Exception { + var createResponse = api.minecraftPost("/v1/minecraft/tickets", Map.of( + "creatorUuid", testUuid, + "creatorName", testUsername, + "type", "bug_report", + "subject", "API Test Ticket - auto cleanup", + "description", "This ticket was created by an automated API test." + )); + JsonHelper.assertStatus(createResponse, 200); + var json = JsonHelper.parseObject(createResponse.body()); + assertTrue(json.has("ticketId")); + String ticketId = json.get("ticketId").getAsString(); + + // DB VERIFICATION: confirm ticket exists in MongoDB + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB after creation"); + assertEquals("bug", dbTicket.getString("type")); + } + + // Cleanup: close via panel + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of( + "status", "closed" + )); + } + + @Test + void createUnfinishedTicket() throws Exception { + var response = api.minecraftPost("/v1/minecraft/tickets/unfinished", Map.of( + "creatorUuid", testUuid, + "creatorName", testUsername, + "type", "bug_report" + )); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("ticketId")); + + // Cleanup + String ticketId = json.get("ticketId").getAsString(); + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void listTickets() throws Exception { + var response = api.minecraftGet("/v1/minecraft/tickets?limit=5"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("tickets")); + } + + @Test + void getTicketById() throws Exception { + var createResponse = api.minecraftPost("/v1/minecraft/tickets", Map.of( + "creatorUuid", testUuid, + "creatorName", testUsername, + "type", "bug_report", + "subject", "API Test - get by id" + )); + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var response = api.minecraftGet("/v1/minecraft/tickets/" + ticketId); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("ticket")); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void getTicketsByPlayer() throws Exception { + var response = api.minecraftGet("/v1/minecraft/tickets/player/" + testUuid); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("tickets")); + } + + @Test + void claimTicket() throws Exception { + var createResponse = api.minecraftPost("/v1/minecraft/tickets", Map.of( + "creatorUuid", testUuid, + "creatorName", testUsername, + "type", "bug_report", + "subject", "API Test - claim" + )); + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var claimResponse = api.minecraftPost("/v1/minecraft/tickets/" + ticketId + "/claim", Map.of( + "playerUuid", testUuid, + "playerName", testUsername + )); + int claimStatus = claimResponse.statusCode(); + // 200 if claimed, 409 if ticket already linked to a player via creation + assertTrue(claimStatus == 200 || claimStatus == 409, "Expected 200 or 409 but got " + claimStatus); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } +} diff --git a/src/test/java/gg/modl/backend/panel/PanelAnalyticsApiTest.java b/src/test/java/gg/modl/backend/panel/PanelAnalyticsApiTest.java index 2603b7b..98f7817 100644 --- a/src/test/java/gg/modl/backend/panel/PanelAnalyticsApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelAnalyticsApiTest.java @@ -1,60 +1,60 @@ -package gg.modl.backend.panel; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelAnalyticsApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - TestDataProvider.repairCorruptedUsernames(); // explicit, server-filtered repair of corrupt staging usernames - } - - @Test - void getOverview() throws Exception { - var response = api.panelGet("/v1/panel/analytics/overview"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getTicketAnalytics() throws Exception { - var response = api.panelGet("/v1/panel/analytics/tickets?period=30d"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getPunishmentAnalytics() throws Exception { - var response = api.panelGet("/v1/panel/analytics/punishments?period=30d"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getAuditLogs() throws Exception { - var response = api.panelGet("/v1/panel/analytics/audit-logs?period=7d"); - JsonHelper.assertStatus(response, 200); - } - - @Disabled("Endpoint deprecated: returns 501, use /audit/staff-performance instead") - @Test - void getStaffPerformance() throws Exception { - var response = api.panelGet("/v1/panel/analytics/staff-performance?period=30d"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getPlayerActivity() throws Exception { - var response = api.panelGet("/v1/panel/analytics/player-activity?period=30d"); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelAnalyticsApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + TestDataProvider.repairCorruptedUsernames(); // explicit, server-filtered repair of corrupt staging usernames + } + + @Test + void getOverview() throws Exception { + var response = api.panelGet("/v1/panel/analytics/overview"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getTicketAnalytics() throws Exception { + var response = api.panelGet("/v1/panel/analytics/tickets?period=30d"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getPunishmentAnalytics() throws Exception { + var response = api.panelGet("/v1/panel/analytics/punishments?period=30d"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getAuditLogs() throws Exception { + var response = api.panelGet("/v1/panel/analytics/audit-logs?period=7d"); + JsonHelper.assertStatus(response, 200); + } + + @Disabled("Endpoint deprecated: returns 501, use /audit/staff-performance instead") + @Test + void getStaffPerformance() throws Exception { + var response = api.panelGet("/v1/panel/analytics/staff-performance?period=30d"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getPlayerActivity() throws Exception { + var response = api.panelGet("/v1/panel/analytics/player-activity?period=30d"); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelAppealApiTest.java b/src/test/java/gg/modl/backend/panel/PanelAppealApiTest.java index f078dea..1fd8ca7 100644 --- a/src/test/java/gg/modl/backend/panel/PanelAppealApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelAppealApiTest.java @@ -1,80 +1,80 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelAppealApiTest { - - static ApiClient api; - - private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getAppealsByPunishment() throws Exception { - // Create a punishment to look up - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", TEST_UUID, - "issuerName", "TestBot", - "type_ordinal", 14, - "reason", "Panel Appeal test", - "duration", 300, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var response = api.panelGet("/v1/panel/appeals/punishment/" + punishmentId); - // No appeals exist for a freshly created punishment - assertEquals(404, response.statusCode()); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } - - @Test - void getAppealById() throws Exception { - // Test with a nonexistent ID to verify the route responds - var response = api.panelGet("/v1/panel/appeals/nonexistent-appeal-id"); - assertEquals(404, response.statusCode(), "Expected 404 for nonexistent appeal"); - } - - @Test - void replyToAppeal() throws Exception { - // Need an existing appeal; test with nonexistent to verify route - var response = api.panelPost("/v1/panel/appeals/nonexistent-appeal-id/replies", Map.of( - "name", "TestBot", - "content", "Test reply", - "type", "staff", - "staff", true - )); - assertEquals(404, response.statusCode(), "Expected 404 for nonexistent appeal"); - } - - @Test - void updateAppealStatus() throws Exception { - var response = api.panelPatch("/v1/panel/appeals/nonexistent-appeal-id/status", Map.of( - "status", "dismissed" - )); - assertEquals(404, response.statusCode(), "Expected 404 for nonexistent appeal"); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelAppealApiTest { + + static ApiClient api; + + private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getAppealsByPunishment() throws Exception { + // Create a punishment to look up + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", TEST_UUID, + "issuerName", "TestBot", + "type_ordinal", 14, + "reason", "Panel Appeal test", + "duration", 300, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var response = api.panelGet("/v1/panel/appeals/punishment/" + punishmentId); + // No appeals exist for a freshly created punishment + assertEquals(404, response.statusCode()); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } + + @Test + void getAppealById() throws Exception { + // Test with a nonexistent ID to verify the route responds + var response = api.panelGet("/v1/panel/appeals/nonexistent-appeal-id"); + assertEquals(404, response.statusCode(), "Expected 404 for nonexistent appeal"); + } + + @Test + void replyToAppeal() throws Exception { + // Need an existing appeal; test with nonexistent to verify route + var response = api.panelPost("/v1/panel/appeals/nonexistent-appeal-id/replies", Map.of( + "name", "TestBot", + "content", "Test reply", + "type", "staff", + "staff", true + )); + assertEquals(404, response.statusCode(), "Expected 404 for nonexistent appeal"); + } + + @Test + void updateAppealStatus() throws Exception { + var response = api.panelPatch("/v1/panel/appeals/nonexistent-appeal-id/status", Map.of( + "status", "dismissed" + )); + assertEquals(404, response.statusCode(), "Expected 404 for nonexistent appeal"); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelAuthApiTest.java b/src/test/java/gg/modl/backend/panel/PanelAuthApiTest.java index 8c17573..bbe0fb8 100644 --- a/src/test/java/gg/modl/backend/panel/PanelAuthApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelAuthApiTest.java @@ -1,68 +1,68 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelAuthApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Disabled("Skipped: would send a real email code to staging user") - @Test - void sendEmailCode() throws Exception {} - - @Disabled("Skipped: requires valid email code") - @Test - void verifyEmailCode() throws Exception {} - - @Disabled("Skipped: would invalidate the test session token") - @Test - void logout() throws Exception {} - - @Test - void getMe() throws Exception { - var response = api.panelGet("/v1/panel/auth/me"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("id") || json.has("email")); - } - - @Test - void getPermissions() throws Exception { - var response = api.panelGet("/v1/panel/auth/permissions"); - JsonHelper.assertStatus(response, 200); - // Response is a JSON array of permission strings - var arr = JsonHelper.parseArray(response.body()); - assertNotNull(arr); - } - - @Test - void updateProfile() throws Exception { - // Get current profile first - var meResponse = api.panelGet("/v1/panel/auth/me"); - var me = JsonHelper.parseObject(meResponse.body()); - String currentUsername = me.has("username") ? me.get("username").getAsString() : "test"; - - // Update with same values (idempotent) - var response = api.panelPatch("/v1/panel/auth/profile", Map.of( - "username", currentUsername - )); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelAuthApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Disabled("Skipped: would send a real email code to staging user") + @Test + void sendEmailCode() throws Exception {} + + @Disabled("Skipped: requires valid email code") + @Test + void verifyEmailCode() throws Exception {} + + @Disabled("Skipped: would invalidate the test session token") + @Test + void logout() throws Exception {} + + @Test + void getMe() throws Exception { + var response = api.panelGet("/v1/panel/auth/me"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("id") || json.has("email")); + } + + @Test + void getPermissions() throws Exception { + var response = api.panelGet("/v1/panel/auth/permissions"); + JsonHelper.assertStatus(response, 200); + // Response is a JSON array of permission strings + var arr = JsonHelper.parseArray(response.body()); + assertNotNull(arr); + } + + @Test + void updateProfile() throws Exception { + // Get current profile first + var meResponse = api.panelGet("/v1/panel/auth/me"); + var me = JsonHelper.parseObject(meResponse.body()); + String currentUsername = me.has("username") ? me.get("username").getAsString() : "test"; + + // Update with same values (idempotent) + var response = api.panelPatch("/v1/panel/auth/profile", Map.of( + "username", currentUsername + )); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelBillingApiTest.java b/src/test/java/gg/modl/backend/panel/PanelBillingApiTest.java index 21db1d4..8b1ca46 100644 --- a/src/test/java/gg/modl/backend/panel/PanelBillingApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelBillingApiTest.java @@ -1,57 +1,57 @@ -package gg.modl.backend.panel; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelBillingApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getBillingStatus() throws Exception { - var response = api.panelGet("/v1/panel/billing/status"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getUsage() throws Exception { - var response = api.panelGet("/v1/panel/billing/usage"); - JsonHelper.assertStatus(response, 200); - } - - @Disabled("Skipped: would create a real Stripe checkout session") - @Test - void createCheckoutSession() throws Exception {} - - @Disabled("Skipped: would create a real Stripe portal session") - @Test - void createPortalSession() throws Exception {} - - @Disabled("Skipped: would cancel real subscription on staging") - @Test - void cancelSubscription() throws Exception {} - - @Disabled("Skipped: would reactivate real subscription on staging") - @Test - void resubscribe() throws Exception {} - - @Disabled("Skipped: would modify usage billing settings on staging") - @Test - void updateUsageSettings() throws Exception {} - - @Disabled("Skipped: would modify storage limits on staging") - @Test - void updateStorageLimit() throws Exception {} -} - +package gg.modl.backend.panel; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelBillingApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getBillingStatus() throws Exception { + var response = api.panelGet("/v1/panel/billing/status"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getUsage() throws Exception { + var response = api.panelGet("/v1/panel/billing/usage"); + JsonHelper.assertStatus(response, 200); + } + + @Disabled("Skipped: would create a real Stripe checkout session") + @Test + void createCheckoutSession() throws Exception {} + + @Disabled("Skipped: would create a real Stripe portal session") + @Test + void createPortalSession() throws Exception {} + + @Disabled("Skipped: would cancel real subscription on staging") + @Test + void cancelSubscription() throws Exception {} + + @Disabled("Skipped: would reactivate real subscription on staging") + @Test + void resubscribe() throws Exception {} + + @Disabled("Skipped: would modify usage billing settings on staging") + @Test + void updateUsageSettings() throws Exception {} + + @Disabled("Skipped: would modify storage limits on staging") + @Test + void updateStorageLimit() throws Exception {} +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelDashboardApiTest.java b/src/test/java/gg/modl/backend/panel/PanelDashboardApiTest.java index 3932941..c48d872 100644 --- a/src/test/java/gg/modl/backend/panel/PanelDashboardApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelDashboardApiTest.java @@ -1,46 +1,46 @@ -package gg.modl.backend.panel; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelDashboardApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - TestDataProvider.repairCorruptedUsernames(); // explicit, server-filtered repair of corrupt staging usernames - } - - @Test - void getMetrics() throws Exception { - var response = api.panelGet("/v1/panel/dashboard/metrics"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getRecentTickets() throws Exception { - var response = api.panelGet("/v1/panel/dashboard/recent-tickets?limit=5"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getRecentPunishments() throws Exception { - var response = api.panelGet("/v1/panel/dashboard/recent-punishments?limit=5"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getRecentActivity() throws Exception { - var response = api.panelGet("/v1/panel/dashboard/activity/recent?limit=10&days=7"); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelDashboardApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + TestDataProvider.repairCorruptedUsernames(); // explicit, server-filtered repair of corrupt staging usernames + } + + @Test + void getMetrics() throws Exception { + var response = api.panelGet("/v1/panel/dashboard/metrics"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getRecentTickets() throws Exception { + var response = api.panelGet("/v1/panel/dashboard/recent-tickets?limit=5"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getRecentPunishments() throws Exception { + var response = api.panelGet("/v1/panel/dashboard/recent-punishments?limit=5"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getRecentActivity() throws Exception { + var response = api.panelGet("/v1/panel/dashboard/activity/recent?limit=10&days=7"); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelHomepageCardApiTest.java b/src/test/java/gg/modl/backend/panel/PanelHomepageCardApiTest.java index 5b281c3..74054b1 100644 --- a/src/test/java/gg/modl/backend/panel/PanelHomepageCardApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelHomepageCardApiTest.java @@ -1,113 +1,113 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDatabase; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelHomepageCardApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void listCards() throws Exception { - var response = api.panelGet("/v1/panel/homepage-cards"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void createAndDeleteCard() throws Exception { - var createResponse = api.panelPost("/v1/panel/homepage-cards", Map.of( - "title", "API Test Card " + System.currentTimeMillis(), - "description", "Created by automated test", - "actionType", "url", - "actionUrl", "https://example.com", - "isEnabled", false - )); - int status = createResponse.statusCode(); - assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); - - var json = JsonHelper.parseObject(createResponse.body()); - String cardId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (cardId == null) { - return; - } - - // DB VERIFICATION: confirm card created - if (TestDatabase.isAvailable()) { - var dbCard = TestDatabase.getInstance().findHomepageCardById(cardId); - assertNotNull(dbCard, "Homepage card should exist in DB after creation"); - } - - // Cleanup - var deleteResponse = api.panelDelete("/v1/panel/homepage-cards/" + cardId); - JsonHelper.assertStatus(deleteResponse, 200); - - // DB VERIFICATION: confirm card deleted - if (TestDatabase.isAvailable()) { - var dbCard = TestDatabase.getInstance().findHomepageCardById(cardId); - assertNull(dbCard, "Homepage card should not exist in DB after deletion"); - } - } - - @Test - void updateCard() throws Exception { - var createResponse = api.panelPost("/v1/panel/homepage-cards", Map.of( - "title", "API Test Update Card", - "isEnabled", false - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - var json = JsonHelper.parseObject(createResponse.body()); - String cardId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (cardId == null) { - return; - } - - var updateResponse = api.panelPut("/v1/panel/homepage-cards/" + cardId, Map.of( - "title", "API Test Card Updated", - "description", "Updated by test" - )); - JsonHelper.assertStatus(updateResponse, 200); - - // Cleanup - api.panelDelete("/v1/panel/homepage-cards/" + cardId); - } - - @Test - void reorderCards() throws Exception { - var listResponse = api.panelGet("/v1/panel/homepage-cards"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.size() < 2) { - return; - } - - List ids = new java.util.ArrayList<>(); - arr.forEach(c -> { - var obj = c.getAsJsonObject(); - ids.add(obj.has("id") ? obj.get("id").getAsString() : obj.get("_id").getAsString()); - }); - - var response = api.panelPut("/v1/panel/homepage-cards/reorder", Map.of("ids", ids)); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDatabase; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelHomepageCardApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void listCards() throws Exception { + var response = api.panelGet("/v1/panel/homepage-cards"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void createAndDeleteCard() throws Exception { + var createResponse = api.panelPost("/v1/panel/homepage-cards", Map.of( + "title", "API Test Card " + System.currentTimeMillis(), + "description", "Created by automated test", + "actionType", "url", + "actionUrl", "https://example.com", + "isEnabled", false + )); + int status = createResponse.statusCode(); + assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); + + var json = JsonHelper.parseObject(createResponse.body()); + String cardId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (cardId == null) { + return; + } + + // DB VERIFICATION: confirm card created + if (TestDatabase.isAvailable()) { + var dbCard = TestDatabase.getInstance().findHomepageCardById(cardId); + assertNotNull(dbCard, "Homepage card should exist in DB after creation"); + } + + // Cleanup + var deleteResponse = api.panelDelete("/v1/panel/homepage-cards/" + cardId); + JsonHelper.assertStatus(deleteResponse, 200); + + // DB VERIFICATION: confirm card deleted + if (TestDatabase.isAvailable()) { + var dbCard = TestDatabase.getInstance().findHomepageCardById(cardId); + assertNull(dbCard, "Homepage card should not exist in DB after deletion"); + } + } + + @Test + void updateCard() throws Exception { + var createResponse = api.panelPost("/v1/panel/homepage-cards", Map.of( + "title", "API Test Update Card", + "isEnabled", false + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + var json = JsonHelper.parseObject(createResponse.body()); + String cardId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (cardId == null) { + return; + } + + var updateResponse = api.panelPut("/v1/panel/homepage-cards/" + cardId, Map.of( + "title", "API Test Card Updated", + "description", "Updated by test" + )); + JsonHelper.assertStatus(updateResponse, 200); + + // Cleanup + api.panelDelete("/v1/panel/homepage-cards/" + cardId); + } + + @Test + void reorderCards() throws Exception { + var listResponse = api.panelGet("/v1/panel/homepage-cards"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.size() < 2) { + return; + } + + List ids = new java.util.ArrayList<>(); + arr.forEach(c -> { + var obj = c.getAsJsonObject(); + ids.add(obj.has("id") ? obj.get("id").getAsString() : obj.get("_id").getAsString()); + }); + + var response = api.panelPut("/v1/panel/homepage-cards/reorder", Map.of("ids", ids)); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelKnowledgebaseApiTest.java b/src/test/java/gg/modl/backend/panel/PanelKnowledgebaseApiTest.java index 86dac81..5ecf933 100644 --- a/src/test/java/gg/modl/backend/panel/PanelKnowledgebaseApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelKnowledgebaseApiTest.java @@ -1,304 +1,304 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDatabase; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelKnowledgebaseApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - // ── Categories ── - - @Test - void listCategories() throws Exception { - var response = api.panelGet("/v1/panel/knowledgebase/categories"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void createAndDeleteCategory() throws Exception { - var createResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( - "name", "API Test Category " + System.currentTimeMillis(), - "description", "Created by automated test" - )); - int status = createResponse.statusCode(); - assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); - - var json = JsonHelper.parseObject(createResponse.body()); - String categoryId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - // DB VERIFICATION: confirm category created - if (TestDatabase.isAvailable()) { - var dbCategory = TestDatabase.getInstance().findKbCategoryById(categoryId); - assertNotNull(dbCategory, "KB category should exist in DB after creation"); - } - - // Cleanup - var deleteResponse = api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - JsonHelper.assertStatus(deleteResponse, 200); - - // DB VERIFICATION: confirm category deleted - if (TestDatabase.isAvailable()) { - var dbCategory = TestDatabase.getInstance().findKbCategoryById(categoryId); - assertNull(dbCategory, "KB category should not exist in DB after deletion"); - } - } - - @Test - void updateCategory() throws Exception { - // Create category - var createResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( - "name", "API Test Update " + System.currentTimeMillis() - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - var json = JsonHelper.parseObject(createResponse.body()); - String categoryId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - var updateResponse = api.panelPut("/v1/panel/knowledgebase/categories/" + categoryId, Map.of( - "name", "API Test Updated " + System.currentTimeMillis(), - "description", "Updated by test" - )); - JsonHelper.assertStatus(updateResponse, 200); - - // Cleanup - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - } - - @Test - void reorderCategories() throws Exception { - var listResponse = api.panelGet("/v1/panel/knowledgebase/categories"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.size() < 2) { - return; - } - - List ids = new java.util.ArrayList<>(); - arr.forEach(c -> { - var obj = c.getAsJsonObject(); - ids.add(obj.has("id") ? obj.get("id").getAsString() : obj.get("_id").getAsString()); - }); - - var response = api.panelPut("/v1/panel/knowledgebase/categories/reorder", Map.of("ids", ids)); - JsonHelper.assertStatus(response, 200); - } - - // ── Articles ── - - @Test - void createAndDeleteArticle() throws Exception { - // Need a category first - var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( - "name", "API Test Article Cat " + System.currentTimeMillis() - )); - if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { - return; - } - var catJson = JsonHelper.parseObject(catResponse.body()); - String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : - catJson.has("_id") ? catJson.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - var articleResponse = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of( - "title", "API Test Article " + System.currentTimeMillis(), - "content", "This is test content.", - "isVisible", true - )); - int articleStatus = articleResponse.statusCode(); - assertTrue(articleStatus == 200 || articleStatus == 201, "Expected 200 or 201 but got " + articleStatus); - - var articleJson = JsonHelper.parseObject(articleResponse.body()); - String articleId = articleJson.has("id") ? articleJson.get("id").getAsString() : - articleJson.has("_id") ? articleJson.get("_id").getAsString() : null; - - // DB VERIFICATION: confirm article created - if (TestDatabase.isAvailable() && articleId != null) { - var dbArticle = TestDatabase.getInstance().findKbArticleById(articleId); - assertNotNull(dbArticle, "KB article should exist in DB after creation"); - } - - if (articleId != null) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); - - // DB VERIFICATION: confirm article deleted - if (TestDatabase.isAvailable()) { - var dbArticle = TestDatabase.getInstance().findKbArticleById(articleId); - assertNull(dbArticle, "KB article should not exist in DB after deletion"); - } - } - // Cleanup category - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - } - - @Test - void listArticles() throws Exception { - var listResponse = api.panelGet("/v1/panel/knowledgebase/categories"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.isEmpty()) { - return; - } - - var cat = arr.get(0).getAsJsonObject(); - String categoryId = cat.has("id") ? cat.get("id").getAsString() : - cat.has("_id") ? cat.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - var response = api.panelGet("/v1/panel/knowledgebase/categories/" + categoryId + "/articles"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getArticleById() throws Exception { - // Create category + article - var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( - "name", "API Test Get Article " + System.currentTimeMillis() - )); - if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { - return; - } - var catJson = JsonHelper.parseObject(catResponse.body()); - String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : - catJson.has("_id") ? catJson.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - var articleResponse = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of( - "title", "API Test Get " + System.currentTimeMillis(), - "content", "Test content for get." - )); - if (articleResponse.statusCode() != 200 && articleResponse.statusCode() != 201) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - return; - } - var articleJson = JsonHelper.parseObject(articleResponse.body()); - String articleId = articleJson.has("id") ? articleJson.get("id").getAsString() : - articleJson.has("_id") ? articleJson.get("_id").getAsString() : null; - if (articleId == null) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - return; - } - - var response = api.panelGet("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - } - - @Test - void updateArticle() throws Exception { - var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( - "name", "API Test Update Article " + System.currentTimeMillis() - )); - if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { - return; - } - var catJson = JsonHelper.parseObject(catResponse.body()); - String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : - catJson.has("_id") ? catJson.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - var articleResponse = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of( - "title", "API Test Article Update", - "content", "Original content" - )); - if (articleResponse.statusCode() != 200 && articleResponse.statusCode() != 201) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - return; - } - var articleJson = JsonHelper.parseObject(articleResponse.body()); - String articleId = articleJson.has("id") ? articleJson.get("id").getAsString() : - articleJson.has("_id") ? articleJson.get("_id").getAsString() : null; - if (articleId == null) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - return; - } - - var response = api.panelPut("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId, Map.of( - "title", "API Test Article Updated", - "content", "Updated content" - )); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - } - - @Test - void reorderArticles() throws Exception { - var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( - "name", "API Test Reorder " + System.currentTimeMillis() - )); - if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { - return; - } - var catJson = JsonHelper.parseObject(catResponse.body()); - String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : - catJson.has("_id") ? catJson.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - // Create 2 articles - var a1 = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of("title", "Art 1", "content", "c1")); - var a2 = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of("title", "Art 2", "content", "c2")); - if (a1.statusCode() != 201 && a1.statusCode() != 200) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - return; - } - if (a2.statusCode() != 201 && a2.statusCode() != 200) { - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - return; - } - - var j1 = JsonHelper.parseObject(a1.body()); - var j2 = JsonHelper.parseObject(a2.body()); - String id1 = j1.has("id") ? j1.get("id").getAsString() : j1.get("_id").getAsString(); - String id2 = j2.has("id") ? j2.get("id").getAsString() : j2.get("_id").getAsString(); - - var response = api.panelPut("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/reorder", - Map.of("ids", List.of(id2, id1))); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + id1); - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + id2); - api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDatabase; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelKnowledgebaseApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + // ── Categories ── + + @Test + void listCategories() throws Exception { + var response = api.panelGet("/v1/panel/knowledgebase/categories"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void createAndDeleteCategory() throws Exception { + var createResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( + "name", "API Test Category " + System.currentTimeMillis(), + "description", "Created by automated test" + )); + int status = createResponse.statusCode(); + assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); + + var json = JsonHelper.parseObject(createResponse.body()); + String categoryId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + // DB VERIFICATION: confirm category created + if (TestDatabase.isAvailable()) { + var dbCategory = TestDatabase.getInstance().findKbCategoryById(categoryId); + assertNotNull(dbCategory, "KB category should exist in DB after creation"); + } + + // Cleanup + var deleteResponse = api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + JsonHelper.assertStatus(deleteResponse, 200); + + // DB VERIFICATION: confirm category deleted + if (TestDatabase.isAvailable()) { + var dbCategory = TestDatabase.getInstance().findKbCategoryById(categoryId); + assertNull(dbCategory, "KB category should not exist in DB after deletion"); + } + } + + @Test + void updateCategory() throws Exception { + // Create category + var createResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( + "name", "API Test Update " + System.currentTimeMillis() + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + var json = JsonHelper.parseObject(createResponse.body()); + String categoryId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + var updateResponse = api.panelPut("/v1/panel/knowledgebase/categories/" + categoryId, Map.of( + "name", "API Test Updated " + System.currentTimeMillis(), + "description", "Updated by test" + )); + JsonHelper.assertStatus(updateResponse, 200); + + // Cleanup + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + } + + @Test + void reorderCategories() throws Exception { + var listResponse = api.panelGet("/v1/panel/knowledgebase/categories"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.size() < 2) { + return; + } + + List ids = new java.util.ArrayList<>(); + arr.forEach(c -> { + var obj = c.getAsJsonObject(); + ids.add(obj.has("id") ? obj.get("id").getAsString() : obj.get("_id").getAsString()); + }); + + var response = api.panelPut("/v1/panel/knowledgebase/categories/reorder", Map.of("ids", ids)); + JsonHelper.assertStatus(response, 200); + } + + // ── Articles ── + + @Test + void createAndDeleteArticle() throws Exception { + // Need a category first + var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( + "name", "API Test Article Cat " + System.currentTimeMillis() + )); + if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { + return; + } + var catJson = JsonHelper.parseObject(catResponse.body()); + String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : + catJson.has("_id") ? catJson.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + var articleResponse = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of( + "title", "API Test Article " + System.currentTimeMillis(), + "content", "This is test content.", + "isVisible", true + )); + int articleStatus = articleResponse.statusCode(); + assertTrue(articleStatus == 200 || articleStatus == 201, "Expected 200 or 201 but got " + articleStatus); + + var articleJson = JsonHelper.parseObject(articleResponse.body()); + String articleId = articleJson.has("id") ? articleJson.get("id").getAsString() : + articleJson.has("_id") ? articleJson.get("_id").getAsString() : null; + + // DB VERIFICATION: confirm article created + if (TestDatabase.isAvailable() && articleId != null) { + var dbArticle = TestDatabase.getInstance().findKbArticleById(articleId); + assertNotNull(dbArticle, "KB article should exist in DB after creation"); + } + + if (articleId != null) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); + + // DB VERIFICATION: confirm article deleted + if (TestDatabase.isAvailable()) { + var dbArticle = TestDatabase.getInstance().findKbArticleById(articleId); + assertNull(dbArticle, "KB article should not exist in DB after deletion"); + } + } + // Cleanup category + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + } + + @Test + void listArticles() throws Exception { + var listResponse = api.panelGet("/v1/panel/knowledgebase/categories"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.isEmpty()) { + return; + } + + var cat = arr.get(0).getAsJsonObject(); + String categoryId = cat.has("id") ? cat.get("id").getAsString() : + cat.has("_id") ? cat.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + var response = api.panelGet("/v1/panel/knowledgebase/categories/" + categoryId + "/articles"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getArticleById() throws Exception { + // Create category + article + var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( + "name", "API Test Get Article " + System.currentTimeMillis() + )); + if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { + return; + } + var catJson = JsonHelper.parseObject(catResponse.body()); + String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : + catJson.has("_id") ? catJson.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + var articleResponse = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of( + "title", "API Test Get " + System.currentTimeMillis(), + "content", "Test content for get." + )); + if (articleResponse.statusCode() != 200 && articleResponse.statusCode() != 201) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + return; + } + var articleJson = JsonHelper.parseObject(articleResponse.body()); + String articleId = articleJson.has("id") ? articleJson.get("id").getAsString() : + articleJson.has("_id") ? articleJson.get("_id").getAsString() : null; + if (articleId == null) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + return; + } + + var response = api.panelGet("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + } + + @Test + void updateArticle() throws Exception { + var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( + "name", "API Test Update Article " + System.currentTimeMillis() + )); + if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { + return; + } + var catJson = JsonHelper.parseObject(catResponse.body()); + String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : + catJson.has("_id") ? catJson.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + var articleResponse = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of( + "title", "API Test Article Update", + "content", "Original content" + )); + if (articleResponse.statusCode() != 200 && articleResponse.statusCode() != 201) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + return; + } + var articleJson = JsonHelper.parseObject(articleResponse.body()); + String articleId = articleJson.has("id") ? articleJson.get("id").getAsString() : + articleJson.has("_id") ? articleJson.get("_id").getAsString() : null; + if (articleId == null) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + return; + } + + var response = api.panelPut("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId, Map.of( + "title", "API Test Article Updated", + "content", "Updated content" + )); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + articleId); + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + } + + @Test + void reorderArticles() throws Exception { + var catResponse = api.panelPost("/v1/panel/knowledgebase/categories", Map.of( + "name", "API Test Reorder " + System.currentTimeMillis() + )); + if (catResponse.statusCode() != 200 && catResponse.statusCode() != 201) { + return; + } + var catJson = JsonHelper.parseObject(catResponse.body()); + String categoryId = catJson.has("id") ? catJson.get("id").getAsString() : + catJson.has("_id") ? catJson.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + // Create 2 articles + var a1 = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of("title", "Art 1", "content", "c1")); + var a2 = api.panelPost("/v1/panel/knowledgebase/categories/" + categoryId + "/articles", Map.of("title", "Art 2", "content", "c2")); + if (a1.statusCode() != 201 && a1.statusCode() != 200) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + return; + } + if (a2.statusCode() != 201 && a2.statusCode() != 200) { + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + return; + } + + var j1 = JsonHelper.parseObject(a1.body()); + var j2 = JsonHelper.parseObject(a2.body()); + String id1 = j1.has("id") ? j1.get("id").getAsString() : j1.get("_id").getAsString(); + String id2 = j2.has("id") ? j2.get("id").getAsString() : j2.get("_id").getAsString(); + + var response = api.panelPut("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/reorder", + Map.of("ids", List.of(id2, id1))); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + id1); + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId + "/articles/" + id2); + api.panelDelete("/v1/panel/knowledgebase/categories/" + categoryId); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelLogApiTest.java b/src/test/java/gg/modl/backend/panel/PanelLogApiTest.java index 7355633..6d9928f 100644 --- a/src/test/java/gg/modl/backend/panel/PanelLogApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelLogApiTest.java @@ -1,26 +1,26 @@ -package gg.modl.backend.panel; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelLogApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getLogs() throws Exception { - var response = api.panelGet("/v1/panel/logs?limit=10"); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelLogApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getLogs() throws Exception { + var response = api.panelGet("/v1/panel/logs?limit=10"); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelMediaApiTest.java b/src/test/java/gg/modl/backend/panel/PanelMediaApiTest.java index be10de9..7d89770 100644 --- a/src/test/java/gg/modl/backend/panel/PanelMediaApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelMediaApiTest.java @@ -1,59 +1,59 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelMediaApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getConfig() throws Exception { - var response = api.panelGet("/v1/panel/media/config"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("backblazeConfigured") || json.has("supportedTypes")); - } - - @Test - void presignUpload() throws Exception { - var response = api.panelPost("/v1/panel/media/presign", Map.of( - "fileName", "test-image.png", - "contentType", "image/png", - "fileSize", 1024, - "uploadType", "ticket" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void confirmUpload() throws Exception { - // This requires a valid key from a completed upload, so test with invalid key - var response = api.panelPost("/v1/panel/media/confirm", Map.of( - "key", "nonexistent-key" - )); - assertEquals(403, response.statusCode()); - } - - @Test - void deleteMedia() throws Exception { - // Test with nonexistent key - var response = api.panelDelete("/v1/panel/media/nonexistent-test-key"); - assertEquals(403, response.statusCode()); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelMediaApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getConfig() throws Exception { + var response = api.panelGet("/v1/panel/media/config"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("backblazeConfigured") || json.has("supportedTypes")); + } + + @Test + void presignUpload() throws Exception { + var response = api.panelPost("/v1/panel/media/presign", Map.of( + "fileName", "test-image.png", + "contentType", "image/png", + "fileSize", 1024, + "uploadType", "ticket" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void confirmUpload() throws Exception { + // This requires a valid key from a completed upload, so test with invalid key + var response = api.panelPost("/v1/panel/media/confirm", Map.of( + "key", "nonexistent-key" + )); + assertEquals(403, response.statusCode()); + } + + @Test + void deleteMedia() throws Exception { + // Test with nonexistent key + var response = api.panelDelete("/v1/panel/media/nonexistent-test-key"); + assertEquals(403, response.statusCode()); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelMigrationApiTest.java b/src/test/java/gg/modl/backend/panel/PanelMigrationApiTest.java index 89583b5..5a34715 100644 --- a/src/test/java/gg/modl/backend/panel/PanelMigrationApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelMigrationApiTest.java @@ -1,39 +1,39 @@ -package gg.modl.backend.panel; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelMigrationApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getMigrationStatus() throws Exception { - var response = api.panelGet("/v1/panel/migration/status"); - JsonHelper.assertStatus(response, 200); - } - - @Disabled("Skipped: would start a real migration on staging") - @Test - void startMigration() throws Exception {} - - @Disabled("Skipped: no active migration to cancel on staging") - @Test - void cancelMigration() throws Exception { - var response = api.panelPost("/v1/panel/migration/cancel", Map.of()); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelMigrationApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getMigrationStatus() throws Exception { + var response = api.panelGet("/v1/panel/migration/status"); + JsonHelper.assertStatus(response, 200); + } + + @Disabled("Skipped: would start a real migration on staging") + @Test + void startMigration() throws Exception {} + + @Disabled("Skipped: no active migration to cancel on staging") + @Test + void cancelMigration() throws Exception { + var response = api.panelPost("/v1/panel/migration/cancel", Map.of()); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelPlayerApiTest.java b/src/test/java/gg/modl/backend/panel/PanelPlayerApiTest.java index b86ff73..195feb1 100644 --- a/src/test/java/gg/modl/backend/panel/PanelPlayerApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelPlayerApiTest.java @@ -1,304 +1,304 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDataProvider.PlayerInfo; -import gg.modl.backend.support.TestDatabase; -import java.util.Map; -import java.util.UUID; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelPlayerApiTest { - - static ApiClient api; - - private static String testUuid; - private static String testUsername; - private static int testTypeOrdinal; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - - PlayerInfo player = TestDataProvider.getPlayers().get(0); - testUuid = player.uuid(); - testUsername = player.username(); - testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); - } - - @Test - void searchPlayers() throws Exception { - var response = api.panelGet("/v1/panel/players?search=" + testUsername); - JsonHelper.assertStatus(response, 200); - var arr = JsonHelper.parseArray(response.body()); - assertNotNull(arr); - } - - @Test - void getPlayerByUuid() throws Exception { - var response = api.panelGet("/v1/panel/players/" + testUuid); - JsonHelper.assertStatus(response, 200); - } - - @Test - void createPlayer() throws Exception { - String uniqueUuid = UUID.randomUUID().toString(); - var response = api.panelPost("/v1/panel/players", Map.of( - "minecraftUuid", uniqueUuid, - "username", "ApiTestPlayer" - )); - int status = response.statusCode(); - assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); - - // DB VERIFICATION: confirm player created - if (TestDatabase.isAvailable()) { - var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(uniqueUuid); - assertNotNull(dbPlayer, "Player should exist in DB after creation"); - } - } - - @Disabled("Server-side bug: Spring Data MongoDB Update.push() wraps Java records in ArrayLists causing 500") - @Test - void addUsername() throws Exception { - // Use a throwaway player to avoid corrupting the main test player - String throwawayUuid = UUID.randomUUID().toString(); - api.panelPost("/v1/panel/players", Map.of( - "minecraftUuid", throwawayUuid, - "username", "ThrowAway" - )); - - var response = api.panelPost("/v1/panel/players/" + throwawayUuid + "/usernames", Map.of( - "username", "TAlias" + (System.currentTimeMillis() % 1000000) - )); - JsonHelper.assertStatus(response, 200); - - // Clean up any corrupted data on the throwaway player - if (TestDatabase.isAvailable()) { - TestDatabase.getInstance().players().deleteOne( - new Document("minecraftUuid", throwawayUuid)); - } - } - - @Test - void addNote() throws Exception { - var response = api.panelPost("/v1/panel/players/" + testUuid + "/notes", Map.of( - "text", "Panel API test note", - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - - // DB VERIFICATION: confirm note exists - if (TestDatabase.isAvailable()) { - var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(testUuid); - assertNotNull(dbPlayer, "Player should exist in DB"); - var notesList = dbPlayer.get("notes"); - assertNotNull(notesList, "Notes list should exist"); - assertTrue(notesList instanceof java.util.List, "Notes should be a list"); - @SuppressWarnings("unchecked") - var notes = (java.util.List) notesList; - assertTrue(notes.stream().anyMatch(n -> { - if (n instanceof Document doc) { - return "Panel API test note".equals(doc.getString("text")); - } - return false; - }), "Should contain the panel test note"); - } - } - - @Test - void addIp() throws Exception { - var response = api.panelPost("/v1/panel/players/" + testUuid + "/ips", Map.of( - "ipAddress", "192.168.1.1" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getActivePunishments() throws Exception { - var response = api.panelGet("/v1/panel/players/" + testUuid + "/punishments/active"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void searchPunishments() throws Exception { - var response = api.panelGet("/v1/panel/players/punishments/search?q=test&activeOnly=false"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getLinkedAccounts() throws Exception { - var response = api.panelGet("/v1/panel/players/" + testUuid + "/linked"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void findLinked() throws Exception { - var response = api.panelPost("/v1/panel/players/" + testUuid + "/find-linked", Map.of()); - JsonHelper.assertStatus(response, 200); - } - - @Test - void createPunishmentFromPanel() throws Exception { - var response = api.panelPost("/v1/panel/players/" + testUuid + "/punishments", Map.of( - "typeOrdinal", testTypeOrdinal, - "reason", "Panel API test - auto cleanup", - "duration", 60, - "severity", "LOW", - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - - // Cleanup via minecraft pardon - api.minecraftPost("/v1/minecraft/players/pardon", Map.of( - "playerName", testUsername, - "issuerName", "TestBot", - "reason", "Panel API test cleanup" - )); - } - - @Test - void addPunishmentNote() throws Exception { - // Create a punishment first - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "Panel API test - punishment note", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var response = api.panelPost("/v1/panel/players/" + testUuid + "/punishments/" + punishmentId + "/notes", Map.of( - "text", "Panel test note", - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - - // DB VERIFICATION: confirm note on punishment - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB"); - var notes = dbPunishment.getList("notes", Document.class); - assertNotNull(notes, "Notes list should exist"); - assertTrue(notes.stream().anyMatch(n -> - "Panel test note".equals(n.getString("text")) - || "Panel test note".equals(n.getString("note"))), - "Should contain the panel test note"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } - - @Test - void getPunishmentById() throws Exception { - // Create a punishment first - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "Panel API test - get by id", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var response = api.panelGet("/v1/panel/players/punishments/" + punishmentId); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } - - @Test - void addEvidence() throws Exception { - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "Panel API test - evidence", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var response = api.panelPost("/v1/panel/players/" + testUuid + "/punishments/" + punishmentId + "/evidence", Map.of( - "url", "https://example.com/evidence.png", - "type", "LINK", - "issuerName", "TestBot" - )); - JsonHelper.assertStatus(response, 200); - - // DB VERIFICATION: confirm evidence added - if (TestDatabase.isAvailable()) { - var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); - assertNotNull(dbPunishment, "Punishment should exist in DB"); - var evidence = dbPunishment.getList("evidence", Document.class); - assertNotNull(evidence, "Evidence list should exist"); - assertFalse(evidence.isEmpty(), "Evidence list should not be empty"); - } - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } - - @Test - void getLinkedBans() throws Exception { - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "Panel API test - linked bans", - "duration", 60, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var response = api.panelGet("/v1/panel/players/punishments/" + punishmentId + "/linked-bans"); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDataProvider.PlayerInfo; +import gg.modl.backend.support.TestDatabase; +import java.util.Map; +import java.util.UUID; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelPlayerApiTest { + + static ApiClient api; + + private static String testUuid; + private static String testUsername; + private static int testTypeOrdinal; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + + PlayerInfo player = TestDataProvider.getPlayers().get(0); + testUuid = player.uuid(); + testUsername = player.username(); + testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); + } + + @Test + void searchPlayers() throws Exception { + var response = api.panelGet("/v1/panel/players?search=" + testUsername); + JsonHelper.assertStatus(response, 200); + var arr = JsonHelper.parseArray(response.body()); + assertNotNull(arr); + } + + @Test + void getPlayerByUuid() throws Exception { + var response = api.panelGet("/v1/panel/players/" + testUuid); + JsonHelper.assertStatus(response, 200); + } + + @Test + void createPlayer() throws Exception { + String uniqueUuid = UUID.randomUUID().toString(); + var response = api.panelPost("/v1/panel/players", Map.of( + "minecraftUuid", uniqueUuid, + "username", "ApiTestPlayer" + )); + int status = response.statusCode(); + assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); + + // DB VERIFICATION: confirm player created + if (TestDatabase.isAvailable()) { + var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(uniqueUuid); + assertNotNull(dbPlayer, "Player should exist in DB after creation"); + } + } + + @Disabled("Server-side bug: Spring Data MongoDB Update.push() wraps Java records in ArrayLists causing 500") + @Test + void addUsername() throws Exception { + // Use a throwaway player to avoid corrupting the main test player + String throwawayUuid = UUID.randomUUID().toString(); + api.panelPost("/v1/panel/players", Map.of( + "minecraftUuid", throwawayUuid, + "username", "ThrowAway" + )); + + var response = api.panelPost("/v1/panel/players/" + throwawayUuid + "/usernames", Map.of( + "username", "TAlias" + (System.currentTimeMillis() % 1000000) + )); + JsonHelper.assertStatus(response, 200); + + // Clean up any corrupted data on the throwaway player + if (TestDatabase.isAvailable()) { + TestDatabase.getInstance().players().deleteOne( + new Document("minecraftUuid", throwawayUuid)); + } + } + + @Test + void addNote() throws Exception { + var response = api.panelPost("/v1/panel/players/" + testUuid + "/notes", Map.of( + "text", "Panel API test note", + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + + // DB VERIFICATION: confirm note exists + if (TestDatabase.isAvailable()) { + var dbPlayer = TestDatabase.getInstance().findPlayerByUuid(testUuid); + assertNotNull(dbPlayer, "Player should exist in DB"); + var notesList = dbPlayer.get("notes"); + assertNotNull(notesList, "Notes list should exist"); + assertTrue(notesList instanceof java.util.List, "Notes should be a list"); + @SuppressWarnings("unchecked") + var notes = (java.util.List) notesList; + assertTrue(notes.stream().anyMatch(n -> { + if (n instanceof Document doc) { + return "Panel API test note".equals(doc.getString("text")); + } + return false; + }), "Should contain the panel test note"); + } + } + + @Test + void addIp() throws Exception { + var response = api.panelPost("/v1/panel/players/" + testUuid + "/ips", Map.of( + "ipAddress", "192.168.1.1" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getActivePunishments() throws Exception { + var response = api.panelGet("/v1/panel/players/" + testUuid + "/punishments/active"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void searchPunishments() throws Exception { + var response = api.panelGet("/v1/panel/players/punishments/search?q=test&activeOnly=false"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getLinkedAccounts() throws Exception { + var response = api.panelGet("/v1/panel/players/" + testUuid + "/linked"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void findLinked() throws Exception { + var response = api.panelPost("/v1/panel/players/" + testUuid + "/find-linked", Map.of()); + JsonHelper.assertStatus(response, 200); + } + + @Test + void createPunishmentFromPanel() throws Exception { + var response = api.panelPost("/v1/panel/players/" + testUuid + "/punishments", Map.of( + "typeOrdinal", testTypeOrdinal, + "reason", "Panel API test - auto cleanup", + "duration", 60, + "severity", "LOW", + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + + // Cleanup via minecraft pardon + api.minecraftPost("/v1/minecraft/players/pardon", Map.of( + "playerName", testUsername, + "issuerName", "TestBot", + "reason", "Panel API test cleanup" + )); + } + + @Test + void addPunishmentNote() throws Exception { + // Create a punishment first + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "Panel API test - punishment note", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var response = api.panelPost("/v1/panel/players/" + testUuid + "/punishments/" + punishmentId + "/notes", Map.of( + "text", "Panel test note", + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + + // DB VERIFICATION: confirm note on punishment + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB"); + var notes = dbPunishment.getList("notes", Document.class); + assertNotNull(notes, "Notes list should exist"); + assertTrue(notes.stream().anyMatch(n -> + "Panel test note".equals(n.getString("text")) + || "Panel test note".equals(n.getString("note"))), + "Should contain the panel test note"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } + + @Test + void getPunishmentById() throws Exception { + // Create a punishment first + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "Panel API test - get by id", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var response = api.panelGet("/v1/panel/players/punishments/" + punishmentId); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } + + @Test + void addEvidence() throws Exception { + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "Panel API test - evidence", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var response = api.panelPost("/v1/panel/players/" + testUuid + "/punishments/" + punishmentId + "/evidence", Map.of( + "url", "https://example.com/evidence.png", + "type", "LINK", + "issuerName", "TestBot" + )); + JsonHelper.assertStatus(response, 200); + + // DB VERIFICATION: confirm evidence added + if (TestDatabase.isAvailable()) { + var dbPunishment = TestDatabase.getInstance().findPunishmentInPlayer(testUuid, punishmentId); + assertNotNull(dbPunishment, "Punishment should exist in DB"); + var evidence = dbPunishment.getList("evidence", Document.class); + assertNotNull(evidence, "Evidence list should exist"); + assertFalse(evidence.isEmpty(), "Evidence list should not be empty"); + } + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } + + @Test + void getLinkedBans() throws Exception { + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "Panel API test - linked bans", + "duration", 60, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var response = api.panelGet("/v1/panel/players/punishments/" + punishmentId + "/linked-bans"); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelRoleApiTest.java b/src/test/java/gg/modl/backend/panel/PanelRoleApiTest.java index 5fed6bf..06c1461 100644 --- a/src/test/java/gg/modl/backend/panel/PanelRoleApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelRoleApiTest.java @@ -1,144 +1,144 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDatabase; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelRoleApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void listRoles() throws Exception { - var response = api.panelGet("/v1/panel/roles"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("roles")); - } - - @Test - void getPermissions() throws Exception { - var response = api.panelGet("/v1/panel/roles/permissions"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("permissions") || json.has("categories")); - } - - @Test - void getRoleById() throws Exception { - var listResponse = api.panelGet("/v1/panel/roles"); - var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); - if (roles.isEmpty()) { - return; - } - - String roleId = roles.get(0).getAsJsonObject().get("id").getAsString(); - var response = api.panelGet("/v1/panel/roles/" + roleId); - JsonHelper.assertStatus(response, 200); - } - - @Test - void createAndDeleteRole() throws Exception { - String roleName = "API Test Role " + System.currentTimeMillis(); - var createResponse = api.panelPost("/v1/panel/roles", Map.of( - "name", roleName, - "description", "Created by automated API test", - "permissions", List.of() - )); - int status = createResponse.statusCode(); - assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); - - var json = JsonHelper.parseObject(createResponse.body()); - String roleId = null; - if (json.has("role")) { - roleId = json.getAsJsonObject("role").get("id").getAsString(); - } else if (json.has("id")) { - roleId = json.get("id").getAsString(); - } - - // DB VERIFICATION: confirm role created - if (TestDatabase.isAvailable() && roleId != null) { - var dbRole = TestDatabase.getInstance().findRoleById(roleId); - assertNotNull(dbRole, "Role should exist in DB after creation"); - } - - if (roleId != null) { - // Cleanup: delete - var deleteResponse = api.panelDelete("/v1/panel/roles/" + roleId); - JsonHelper.assertStatus(deleteResponse, 200); - - // DB VERIFICATION: confirm role deleted - if (TestDatabase.isAvailable()) { - var dbRole = TestDatabase.getInstance().findRoleById(roleId); - assertNull(dbRole, "Role should not exist in DB after deletion"); - } - } - } - - @Test - void updateRole() throws Exception { - // Create a role to update - var createResponse = api.panelPost("/v1/panel/roles", Map.of( - "name", "API Test Update " + System.currentTimeMillis(), - "description", "Will be updated", - "permissions", List.of() - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - - var json = JsonHelper.parseObject(createResponse.body()); - String roleId = json.has("role") ? - json.getAsJsonObject("role").get("id").getAsString() : - json.has("id") ? json.get("id").getAsString() : null; - if (roleId == null) { - return; - } - - var updateResponse = api.panelPut("/v1/panel/roles/" + roleId, Map.of( - "name", "API Test Updated " + System.currentTimeMillis(), - "description", "Updated by test", - "permissions", List.of() - )); - JsonHelper.assertStatus(updateResponse, 200); - - // Cleanup - api.panelDelete("/v1/panel/roles/" + roleId); - } - - @Test - void reorderRoles() throws Exception { - var listResponse = api.panelGet("/v1/panel/roles"); - var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); - if (roles.size() < 2) { - return; - } - - // Build roleOrder with id and order pairs - List> roleOrder = new java.util.ArrayList<>(); - for (int i = 0; i < roles.size(); i++) { - String id = roles.get(i).getAsJsonObject().get("id").getAsString(); - roleOrder.add(Map.of("id", id, "order", i)); - } - - var response = api.panelPost("/v1/panel/roles/reorder", Map.of("roleOrder", roleOrder)); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDatabase; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelRoleApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void listRoles() throws Exception { + var response = api.panelGet("/v1/panel/roles"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("roles")); + } + + @Test + void getPermissions() throws Exception { + var response = api.panelGet("/v1/panel/roles/permissions"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("permissions") || json.has("categories")); + } + + @Test + void getRoleById() throws Exception { + var listResponse = api.panelGet("/v1/panel/roles"); + var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); + if (roles.isEmpty()) { + return; + } + + String roleId = roles.get(0).getAsJsonObject().get("id").getAsString(); + var response = api.panelGet("/v1/panel/roles/" + roleId); + JsonHelper.assertStatus(response, 200); + } + + @Test + void createAndDeleteRole() throws Exception { + String roleName = "API Test Role " + System.currentTimeMillis(); + var createResponse = api.panelPost("/v1/panel/roles", Map.of( + "name", roleName, + "description", "Created by automated API test", + "permissions", List.of() + )); + int status = createResponse.statusCode(); + assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); + + var json = JsonHelper.parseObject(createResponse.body()); + String roleId = null; + if (json.has("role")) { + roleId = json.getAsJsonObject("role").get("id").getAsString(); + } else if (json.has("id")) { + roleId = json.get("id").getAsString(); + } + + // DB VERIFICATION: confirm role created + if (TestDatabase.isAvailable() && roleId != null) { + var dbRole = TestDatabase.getInstance().findRoleById(roleId); + assertNotNull(dbRole, "Role should exist in DB after creation"); + } + + if (roleId != null) { + // Cleanup: delete + var deleteResponse = api.panelDelete("/v1/panel/roles/" + roleId); + JsonHelper.assertStatus(deleteResponse, 200); + + // DB VERIFICATION: confirm role deleted + if (TestDatabase.isAvailable()) { + var dbRole = TestDatabase.getInstance().findRoleById(roleId); + assertNull(dbRole, "Role should not exist in DB after deletion"); + } + } + } + + @Test + void updateRole() throws Exception { + // Create a role to update + var createResponse = api.panelPost("/v1/panel/roles", Map.of( + "name", "API Test Update " + System.currentTimeMillis(), + "description", "Will be updated", + "permissions", List.of() + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + + var json = JsonHelper.parseObject(createResponse.body()); + String roleId = json.has("role") ? + json.getAsJsonObject("role").get("id").getAsString() : + json.has("id") ? json.get("id").getAsString() : null; + if (roleId == null) { + return; + } + + var updateResponse = api.panelPut("/v1/panel/roles/" + roleId, Map.of( + "name", "API Test Updated " + System.currentTimeMillis(), + "description", "Updated by test", + "permissions", List.of() + )); + JsonHelper.assertStatus(updateResponse, 200); + + // Cleanup + api.panelDelete("/v1/panel/roles/" + roleId); + } + + @Test + void reorderRoles() throws Exception { + var listResponse = api.panelGet("/v1/panel/roles"); + var roles = JsonHelper.parseObject(listResponse.body()).getAsJsonArray("roles"); + if (roles.size() < 2) { + return; + } + + // Build roleOrder with id and order pairs + List> roleOrder = new java.util.ArrayList<>(); + for (int i = 0; i < roles.size(); i++) { + String id = roles.get(i).getAsJsonObject().get("id").getAsString(); + roleOrder.add(Map.of("id", id, "order", i)); + } + + var response = api.panelPost("/v1/panel/roles/reorder", Map.of("roleOrder", roleOrder)); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelSettingsApiTest.java b/src/test/java/gg/modl/backend/panel/PanelSettingsApiTest.java index b7f16a1..0b8c96a 100644 --- a/src/test/java/gg/modl/backend/panel/PanelSettingsApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelSettingsApiTest.java @@ -1,125 +1,125 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelSettingsApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - // -- Punishment Types -- - - @Test - void getPunishmentTypes() throws Exception { - var response = api.panelGet("/v1/panel/settings/punishment-types"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getPunishmentTypeByOrdinal() throws Exception { - var response = api.panelGet("/v1/panel/settings/punishment-types/0"); - JsonHelper.assertStatus(response, 200); - } - - @Disabled("Skipped: would reset punishment types to defaults on staging") - @Test - void resetPunishmentTypes() throws Exception {} - - // -- General Settings -- - - @Test - void getGeneralSettings() throws Exception { - var response = api.panelGet("/v1/panel/settings/general"); - // Test user is not super admin, expects 403 - assertEquals(403, response.statusCode()); - } - - // -- Status Thresholds -- - - @Test - void getStatusThresholds() throws Exception { - var response = api.panelGet("/v1/panel/settings/status-thresholds"); - JsonHelper.assertStatus(response, 200); - } - - // -- API Keys -- - - @Test - void checkApiKeyExists() throws Exception { - var response = api.panelGet("/v1/panel/settings/api-keys/minecraft/exists"); - // Test user is not super admin, expects 403 - assertEquals(403, response.statusCode()); - } - - @Test - void revealApiKey() throws Exception { - var response = api.panelGet("/v1/panel/settings/api-keys/minecraft/reveal"); - // Test user is not super admin, expects 403 - assertEquals(403, response.statusCode()); - } - - @Disabled("Skipped: would rotate the real API key on staging") - @Test - void generateApiKey() throws Exception {} - - // -- AI Moderation -- - - @Test - void getAiModerationSettings() throws Exception { - var response = api.panelGet("/v1/panel/settings/ai-moderation"); - JsonHelper.assertStatus(response, 200); - } - - // -- Webhooks -- - - @Test - void getWebhookSettings() throws Exception { - var response = api.panelGet("/v1/panel/settings/webhooks"); - JsonHelper.assertStatus(response, 200); - } - - // -- Ticket Forms -- - - @Test - void getTicketForms() throws Exception { - var response = api.panelGet("/v1/panel/settings/ticket-forms"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getTicketFormByType() throws Exception { - var response = api.panelGet("/v1/panel/settings/ticket-forms/bug_report"); - // Ticket form type may not exist on staging - assertEquals(404, response.statusCode()); - } - - // -- Domain Settings -- - - @Test - void getDomainSettings() throws Exception { - var response = api.panelGet("/v1/panel/settings/domain"); - JsonHelper.assertStatus(response, 200); - } - - // -- Quick Responses -- - - @Test - void getQuickResponses() throws Exception { - var response = api.panelGet("/v1/panel/settings/quick-responses"); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelSettingsApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + // -- Punishment Types -- + + @Test + void getPunishmentTypes() throws Exception { + var response = api.panelGet("/v1/panel/settings/punishment-types"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getPunishmentTypeByOrdinal() throws Exception { + var response = api.panelGet("/v1/panel/settings/punishment-types/0"); + JsonHelper.assertStatus(response, 200); + } + + @Disabled("Skipped: would reset punishment types to defaults on staging") + @Test + void resetPunishmentTypes() throws Exception {} + + // -- General Settings -- + + @Test + void getGeneralSettings() throws Exception { + var response = api.panelGet("/v1/panel/settings/general"); + // Test user is not super admin, expects 403 + assertEquals(403, response.statusCode()); + } + + // -- Status Thresholds -- + + @Test + void getStatusThresholds() throws Exception { + var response = api.panelGet("/v1/panel/settings/status-thresholds"); + JsonHelper.assertStatus(response, 200); + } + + // -- API Keys -- + + @Test + void checkApiKeyExists() throws Exception { + var response = api.panelGet("/v1/panel/settings/api-keys/minecraft/exists"); + // Test user is not super admin, expects 403 + assertEquals(403, response.statusCode()); + } + + @Test + void revealApiKey() throws Exception { + var response = api.panelGet("/v1/panel/settings/api-keys/minecraft/reveal"); + // Test user is not super admin, expects 403 + assertEquals(403, response.statusCode()); + } + + @Disabled("Skipped: would rotate the real API key on staging") + @Test + void generateApiKey() throws Exception {} + + // -- AI Moderation -- + + @Test + void getAiModerationSettings() throws Exception { + var response = api.panelGet("/v1/panel/settings/ai-moderation"); + JsonHelper.assertStatus(response, 200); + } + + // -- Webhooks -- + + @Test + void getWebhookSettings() throws Exception { + var response = api.panelGet("/v1/panel/settings/webhooks"); + JsonHelper.assertStatus(response, 200); + } + + // -- Ticket Forms -- + + @Test + void getTicketForms() throws Exception { + var response = api.panelGet("/v1/panel/settings/ticket-forms"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getTicketFormByType() throws Exception { + var response = api.panelGet("/v1/panel/settings/ticket-forms/bug_report"); + // Ticket form type may not exist on staging + assertEquals(404, response.statusCode()); + } + + // -- Domain Settings -- + + @Test + void getDomainSettings() throws Exception { + var response = api.panelGet("/v1/panel/settings/domain"); + JsonHelper.assertStatus(response, 200); + } + + // -- Quick Responses -- + + @Test + void getQuickResponses() throws Exception { + var response = api.panelGet("/v1/panel/settings/quick-responses"); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelStaffApiTest.java b/src/test/java/gg/modl/backend/panel/PanelStaffApiTest.java index f26ae86..e44f73e 100644 --- a/src/test/java/gg/modl/backend/panel/PanelStaffApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelStaffApiTest.java @@ -1,157 +1,157 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDatabase; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelStaffApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void listStaff() throws Exception { - var response = api.panelGet("/v1/panel/staff"); - JsonHelper.assertStatus(response, 200); - var arr = JsonHelper.parseArray(response.body()); - assertNotNull(arr); - } - - @Test - void getStaffByUsername() throws Exception { - // Get first staff member's username - var listResponse = api.panelGet("/v1/panel/staff"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.isEmpty()) { - return; - } - String username = arr.get(0).getAsJsonObject().get("username").getAsString(); - - var response = api.panelGet("/v1/panel/staff/" + username); - JsonHelper.assertStatus(response, 200); - } - - @Test - void checkUsername() throws Exception { - var response = api.panelGet("/v1/panel/staff/check-username/nonexistent-test-user-12345"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("exists")); - } - - @Test - void createAndDeleteStaff() throws Exception { - String testUsername = "apitest" + System.currentTimeMillis(); - String testEmail = "api-test-" + System.currentTimeMillis() + "@example.com"; - var createResponse = api.panelPost("/v1/panel/staff", Map.of( - "email", testEmail, - "username", testUsername, - "role", "Moderator" - )); - int status = createResponse.statusCode(); - assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); - - // DB VERIFICATION: confirm staff created - if (TestDatabase.isAvailable()) { - var dbStaff = TestDatabase.getInstance().findStaffByUsername(testUsername); - assertNotNull(dbStaff, "Staff should exist in DB after creation"); - } - - var json = JsonHelper.parseObject(createResponse.body()); - String staffId = json.has("id") ? json.get("id").getAsString() : null; - if (staffId != null) { - // Cleanup: delete - api.panelDelete("/v1/panel/staff/" + staffId); - - // DB VERIFICATION: confirm staff deleted - if (TestDatabase.isAvailable()) { - var dbStaff = TestDatabase.getInstance().findStaffByUsername(testUsername); - assertNull(dbStaff, "Staff should not exist in DB after deletion"); - } - } - } - - @Test - void updateStaff() throws Exception { - var listResponse = api.panelGet("/v1/panel/staff"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.isEmpty()) { - return; - } - var staff = arr.get(0).getAsJsonObject(); - String username = staff.get("username").getAsString(); - - // Idempotent update - var response = api.panelPatch("/v1/panel/staff/" + username, Map.of( - "username", username - )); - JsonHelper.assertStatus(response, 200); - } - - @Disabled("Skipped: endpoint returns 404 for staff role update in staging") - @Test - void updateStaffRole() throws Exception { - var listResponse = api.panelGet("/v1/panel/staff"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.size() < 2) { - return; // need at least 2 staff to safely test - } - - var staff = arr.get(arr.size() - 1).getAsJsonObject(); // last staff member - String staffId = staff.get("id").getAsString(); - String currentRole = staff.has("role") ? staff.get("role").getAsString() : "Moderator"; - - // Set same role (idempotent) - var response = api.panelPatch("/v1/panel/staff/" + staffId + "/role", Map.of( - "role", currentRole - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void inviteStaff() throws Exception { - var response = api.panelPost("/v1/panel/staff/invite", Map.of( - "email", "invite-test-" + System.currentTimeMillis() + "@example.com", - "role", "Moderator" - )); - int inviteStatus = response.statusCode(); - assertTrue(inviteStatus == 200 || inviteStatus == 201, "Expected 200 or 201 but got " + inviteStatus); - } - - @Test - void assignMinecraftPlayer() throws Exception { - var listResponse = api.panelGet("/v1/panel/staff"); - var arr = JsonHelper.parseArray(listResponse.body()); - if (arr.isEmpty()) { - return; - } - String email = arr.get(0).getAsJsonObject().get("email").getAsString(); - - var response = api.panelPatch("/v1/panel/staff/" + email + "/minecraft-player", Map.of()); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getAvailablePlayers() throws Exception { - var response = api.panelGet("/v1/panel/staff/available-players"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("players")); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDatabase; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelStaffApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void listStaff() throws Exception { + var response = api.panelGet("/v1/panel/staff"); + JsonHelper.assertStatus(response, 200); + var arr = JsonHelper.parseArray(response.body()); + assertNotNull(arr); + } + + @Test + void getStaffByUsername() throws Exception { + // Get first staff member's username + var listResponse = api.panelGet("/v1/panel/staff"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.isEmpty()) { + return; + } + String username = arr.get(0).getAsJsonObject().get("username").getAsString(); + + var response = api.panelGet("/v1/panel/staff/" + username); + JsonHelper.assertStatus(response, 200); + } + + @Test + void checkUsername() throws Exception { + var response = api.panelGet("/v1/panel/staff/check-username/nonexistent-test-user-12345"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("exists")); + } + + @Test + void createAndDeleteStaff() throws Exception { + String testUsername = "apitest" + System.currentTimeMillis(); + String testEmail = "api-test-" + System.currentTimeMillis() + "@example.com"; + var createResponse = api.panelPost("/v1/panel/staff", Map.of( + "email", testEmail, + "username", testUsername, + "role", "Moderator" + )); + int status = createResponse.statusCode(); + assertTrue(status == 200 || status == 201, "Expected 200 or 201 but got " + status); + + // DB VERIFICATION: confirm staff created + if (TestDatabase.isAvailable()) { + var dbStaff = TestDatabase.getInstance().findStaffByUsername(testUsername); + assertNotNull(dbStaff, "Staff should exist in DB after creation"); + } + + var json = JsonHelper.parseObject(createResponse.body()); + String staffId = json.has("id") ? json.get("id").getAsString() : null; + if (staffId != null) { + // Cleanup: delete + api.panelDelete("/v1/panel/staff/" + staffId); + + // DB VERIFICATION: confirm staff deleted + if (TestDatabase.isAvailable()) { + var dbStaff = TestDatabase.getInstance().findStaffByUsername(testUsername); + assertNull(dbStaff, "Staff should not exist in DB after deletion"); + } + } + } + + @Test + void updateStaff() throws Exception { + var listResponse = api.panelGet("/v1/panel/staff"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.isEmpty()) { + return; + } + var staff = arr.get(0).getAsJsonObject(); + String username = staff.get("username").getAsString(); + + // Idempotent update + var response = api.panelPatch("/v1/panel/staff/" + username, Map.of( + "username", username + )); + JsonHelper.assertStatus(response, 200); + } + + @Disabled("Skipped: endpoint returns 404 for staff role update in staging") + @Test + void updateStaffRole() throws Exception { + var listResponse = api.panelGet("/v1/panel/staff"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.size() < 2) { + return; // need at least 2 staff to safely test + } + + var staff = arr.get(arr.size() - 1).getAsJsonObject(); // last staff member + String staffId = staff.get("id").getAsString(); + String currentRole = staff.has("role") ? staff.get("role").getAsString() : "Moderator"; + + // Set same role (idempotent) + var response = api.panelPatch("/v1/panel/staff/" + staffId + "/role", Map.of( + "role", currentRole + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void inviteStaff() throws Exception { + var response = api.panelPost("/v1/panel/staff/invite", Map.of( + "email", "invite-test-" + System.currentTimeMillis() + "@example.com", + "role", "Moderator" + )); + int inviteStatus = response.statusCode(); + assertTrue(inviteStatus == 200 || inviteStatus == 201, "Expected 200 or 201 but got " + inviteStatus); + } + + @Test + void assignMinecraftPlayer() throws Exception { + var listResponse = api.panelGet("/v1/panel/staff"); + var arr = JsonHelper.parseArray(listResponse.body()); + if (arr.isEmpty()) { + return; + } + String email = arr.get(0).getAsJsonObject().get("email").getAsString(); + + var response = api.panelPatch("/v1/panel/staff/" + email + "/minecraft-player", Map.of()); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getAvailablePlayers() throws Exception { + var response = api.panelGet("/v1/panel/staff/available-players"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("players")); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelStorageApiTest.java b/src/test/java/gg/modl/backend/panel/PanelStorageApiTest.java index e19d2c7..496e5a8 100644 --- a/src/test/java/gg/modl/backend/panel/PanelStorageApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelStorageApiTest.java @@ -1,59 +1,59 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PanelStorageApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getQuota() throws Exception { - var response = api.panelGet("/v1/panel/storage/quota"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void listFiles() throws Exception { - var response = api.panelGet("/v1/panel/storage/files"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("files")); - } - - @Test - void listFilesWithPrefix() throws Exception { - var response = api.panelGet("/v1/panel/storage/files?prefix=test"); - JsonHelper.assertStatus(response, 200); - } - - @Disabled("Skipped: test user lacks storage download permission") - @Test - void downloadFile() throws Exception { - // Get a file key first - var listResponse = api.panelGet("/v1/panel/storage/files"); - var json = JsonHelper.parseObject(listResponse.body()); - var files = json.getAsJsonArray("files"); - if (files.isEmpty()) { - return; - } - - String key = files.get(0).getAsJsonObject().get("key").getAsString(); - var response = api.panelGet("/v1/panel/storage/download/" + key); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PanelStorageApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getQuota() throws Exception { + var response = api.panelGet("/v1/panel/storage/quota"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void listFiles() throws Exception { + var response = api.panelGet("/v1/panel/storage/files"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("files")); + } + + @Test + void listFilesWithPrefix() throws Exception { + var response = api.panelGet("/v1/panel/storage/files?prefix=test"); + JsonHelper.assertStatus(response, 200); + } + + @Disabled("Skipped: test user lacks storage download permission") + @Test + void downloadFile() throws Exception { + // Get a file key first + var listResponse = api.panelGet("/v1/panel/storage/files"); + var json = JsonHelper.parseObject(listResponse.body()); + var files = json.getAsJsonArray("files"); + if (files.isEmpty()) { + return; + } + + String key = files.get(0).getAsJsonObject().get("key").getAsString(); + var response = api.panelGet("/v1/panel/storage/download/" + key); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelTicketApiTest.java b/src/test/java/gg/modl/backend/panel/PanelTicketApiTest.java index 856e231..221112f 100644 --- a/src/test/java/gg/modl/backend/panel/PanelTicketApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelTicketApiTest.java @@ -1,286 +1,286 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDatabase; -import java.util.List; -import java.util.Map; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelTicketApiTest { - - static ApiClient api; - - private static String testUuid; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - - testUuid = TestDataProvider.getPlayers().get(0).uuid(); - } - - @Test - void searchTickets() throws Exception { - var response = api.panelGet("/v1/panel/tickets?page=1&limit=5"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getTicketCounts() throws Exception { - var response = api.panelGet("/v1/panel/tickets/counts"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void createAndCleanupTicket() throws Exception { - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test Ticket", - "description", "Created by automated test", - "creatorName", "TestBot" - )); - int createStatus = createResponse.statusCode(); - assertTrue(createStatus == 200 || createStatus == 201, "Expected 200 or 201 but got " + createStatus); - - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = null; - if (json.has("id")) { - ticketId = json.get("id").getAsString(); - } else if (json.has("_id")) { - ticketId = json.get("_id").getAsString(); - } - - // DB VERIFICATION: confirm ticket created - if (TestDatabase.isAvailable() && ticketId != null) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB after creation"); - } - - if (ticketId != null) { - // Cleanup: close ticket - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - } - - @Test - void getTicketById() throws Exception { - // Create - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test - get by id" - )); - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (ticketId == null) { - return; - } - - var response = api.panelGet("/v1/panel/tickets/" + ticketId); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void updateTicket() throws Exception { - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test - update" - )); - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (ticketId == null) { - return; - } - - var response = api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of( - "status", "in_progress" - )); - JsonHelper.assertStatus(response, 200); - - // DB VERIFICATION: confirm status changed - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB"); - // "in_progress" is an alias for TicketStatus.OPEN; the canonical id "open" is what is persisted to Mongo. - assertEquals("open", dbTicket.getString("status")); - } - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void addNoteToTicket() throws Exception { - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test - add note" - )); - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (ticketId == null) { - return; - } - - var response = api.panelPost("/v1/panel/tickets/" + ticketId + "/notes", Map.of( - "text", "Panel API test note", - "issuerName", "TestBot" - )); - int noteStatus = response.statusCode(); - assertTrue(noteStatus == 200 || noteStatus == 201, "Expected 200 or 201 but got " + noteStatus); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void addReplyToTicket() throws Exception { - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test - add reply" - )); - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (ticketId == null) { - return; - } - - var response = api.panelPost("/v1/panel/tickets/" + ticketId + "/replies", Map.of( - "name", "TestBot", - "content", "Automated test reply", - "staff", true - )); - int replyStatus = response.statusCode(); - assertTrue(replyStatus == 200 || replyStatus == 201, "Expected 200 or 201 but got " + replyStatus); - - // DB VERIFICATION: confirm reply added - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB"); - var replies = dbTicket.getList("replies", Document.class); - assertNotNull(replies, "Replies list should exist"); - assertTrue(replies.stream().anyMatch(r -> - "Automated test reply".equals(r.getString("content"))), - "Should contain the test reply"); - } - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void addTag() throws Exception { - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test - tags" - )); - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (ticketId == null) { - return; - } - - var response = api.panelPost("/v1/panel/tickets/" + ticketId + "/tags", Map.of( - "tag", "api-test" - )); - JsonHelper.assertStatus(response, 200); - - // DB VERIFICATION: confirm tag added - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB"); - var tags = dbTicket.getList("tags", String.class); - assertNotNull(tags, "Tags list should exist"); - assertTrue(tags.contains("api-test"), "Should contain the api-test tag"); - } - - // Remove tag - api.panelDelete("/v1/panel/tickets/" + ticketId + "/tags/api-test"); - - // DB VERIFICATION: confirm tag removed - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB"); - var tags = dbTicket.getList("tags", String.class); - assertTrue(tags == null || !tags.contains("api-test"), "Tag should be removed"); - } - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void removeTag() throws Exception { - var createResponse = api.panelPost("/v1/panel/tickets", Map.of( - "type", "bug_report", - "subject", "Panel API Test - remove tag" - )); - var json = JsonHelper.parseObject(createResponse.body()); - String ticketId = json.has("id") ? json.get("id").getAsString() : - json.has("_id") ? json.get("_id").getAsString() : null; - if (ticketId == null) { - return; - } - - // Add then remove - api.panelPost("/v1/panel/tickets/" + ticketId + "/tags", Map.of("tag", "temp-tag")); - var response = api.panelDelete("/v1/panel/tickets/" + ticketId + "/tags/temp-tag"); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void getTicketsByPlayer() throws Exception { - var response = api.panelGet("/v1/panel/tickets/player/" + testUuid); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getTicketsByTag() throws Exception { - var response = api.panelGet("/v1/panel/tickets/tag/api-test"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void bulkUpdate() throws Exception { - // Create two tickets - var r1 = api.panelPost("/v1/panel/tickets", Map.of("type", "bug_report", "subject", "Bulk test 1")); - var r2 = api.panelPost("/v1/panel/tickets", Map.of("type", "bug_report", "subject", "Bulk test 2")); - var j1 = JsonHelper.parseObject(r1.body()); - var j2 = JsonHelper.parseObject(r2.body()); - String id1 = j1.has("id") ? j1.get("id").getAsString() : j1.has("_id") ? j1.get("_id").getAsString() : null; - String id2 = j2.has("id") ? j2.get("id").getAsString() : j2.has("_id") ? j2.get("_id").getAsString() : null; - if (id1 == null || id2 == null) { - return; - } - - var response = api.panelPost("/v1/panel/tickets/bulk", Map.of( - "ticketIds", List.of(id1, id2) - )); - JsonHelper.assertStatus(response, 200); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + id1, Map.of("status", "closed")); - api.panelPatch("/v1/panel/tickets/" + id2, Map.of("status", "closed")); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDatabase; +import java.util.List; +import java.util.Map; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelTicketApiTest { + + static ApiClient api; + + private static String testUuid; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + + testUuid = TestDataProvider.getPlayers().get(0).uuid(); + } + + @Test + void searchTickets() throws Exception { + var response = api.panelGet("/v1/panel/tickets?page=1&limit=5"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getTicketCounts() throws Exception { + var response = api.panelGet("/v1/panel/tickets/counts"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void createAndCleanupTicket() throws Exception { + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test Ticket", + "description", "Created by automated test", + "creatorName", "TestBot" + )); + int createStatus = createResponse.statusCode(); + assertTrue(createStatus == 200 || createStatus == 201, "Expected 200 or 201 but got " + createStatus); + + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = null; + if (json.has("id")) { + ticketId = json.get("id").getAsString(); + } else if (json.has("_id")) { + ticketId = json.get("_id").getAsString(); + } + + // DB VERIFICATION: confirm ticket created + if (TestDatabase.isAvailable() && ticketId != null) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB after creation"); + } + + if (ticketId != null) { + // Cleanup: close ticket + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + } + + @Test + void getTicketById() throws Exception { + // Create + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test - get by id" + )); + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (ticketId == null) { + return; + } + + var response = api.panelGet("/v1/panel/tickets/" + ticketId); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void updateTicket() throws Exception { + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test - update" + )); + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (ticketId == null) { + return; + } + + var response = api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of( + "status", "in_progress" + )); + JsonHelper.assertStatus(response, 200); + + // DB VERIFICATION: confirm status changed + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB"); + // "in_progress" is an alias for TicketStatus.OPEN; the canonical id "open" is what is persisted to Mongo. + assertEquals("open", dbTicket.getString("status")); + } + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void addNoteToTicket() throws Exception { + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test - add note" + )); + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (ticketId == null) { + return; + } + + var response = api.panelPost("/v1/panel/tickets/" + ticketId + "/notes", Map.of( + "text", "Panel API test note", + "issuerName", "TestBot" + )); + int noteStatus = response.statusCode(); + assertTrue(noteStatus == 200 || noteStatus == 201, "Expected 200 or 201 but got " + noteStatus); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void addReplyToTicket() throws Exception { + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test - add reply" + )); + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (ticketId == null) { + return; + } + + var response = api.panelPost("/v1/panel/tickets/" + ticketId + "/replies", Map.of( + "name", "TestBot", + "content", "Automated test reply", + "staff", true + )); + int replyStatus = response.statusCode(); + assertTrue(replyStatus == 200 || replyStatus == 201, "Expected 200 or 201 but got " + replyStatus); + + // DB VERIFICATION: confirm reply added + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB"); + var replies = dbTicket.getList("replies", Document.class); + assertNotNull(replies, "Replies list should exist"); + assertTrue(replies.stream().anyMatch(r -> + "Automated test reply".equals(r.getString("content"))), + "Should contain the test reply"); + } + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void addTag() throws Exception { + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test - tags" + )); + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (ticketId == null) { + return; + } + + var response = api.panelPost("/v1/panel/tickets/" + ticketId + "/tags", Map.of( + "tag", "api-test" + )); + JsonHelper.assertStatus(response, 200); + + // DB VERIFICATION: confirm tag added + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB"); + var tags = dbTicket.getList("tags", String.class); + assertNotNull(tags, "Tags list should exist"); + assertTrue(tags.contains("api-test"), "Should contain the api-test tag"); + } + + // Remove tag + api.panelDelete("/v1/panel/tickets/" + ticketId + "/tags/api-test"); + + // DB VERIFICATION: confirm tag removed + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB"); + var tags = dbTicket.getList("tags", String.class); + assertTrue(tags == null || !tags.contains("api-test"), "Tag should be removed"); + } + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void removeTag() throws Exception { + var createResponse = api.panelPost("/v1/panel/tickets", Map.of( + "type", "bug_report", + "subject", "Panel API Test - remove tag" + )); + var json = JsonHelper.parseObject(createResponse.body()); + String ticketId = json.has("id") ? json.get("id").getAsString() : + json.has("_id") ? json.get("_id").getAsString() : null; + if (ticketId == null) { + return; + } + + // Add then remove + api.panelPost("/v1/panel/tickets/" + ticketId + "/tags", Map.of("tag", "temp-tag")); + var response = api.panelDelete("/v1/panel/tickets/" + ticketId + "/tags/temp-tag"); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void getTicketsByPlayer() throws Exception { + var response = api.panelGet("/v1/panel/tickets/player/" + testUuid); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getTicketsByTag() throws Exception { + var response = api.panelGet("/v1/panel/tickets/tag/api-test"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void bulkUpdate() throws Exception { + // Create two tickets + var r1 = api.panelPost("/v1/panel/tickets", Map.of("type", "bug_report", "subject", "Bulk test 1")); + var r2 = api.panelPost("/v1/panel/tickets", Map.of("type", "bug_report", "subject", "Bulk test 2")); + var j1 = JsonHelper.parseObject(r1.body()); + var j2 = JsonHelper.parseObject(r2.body()); + String id1 = j1.has("id") ? j1.get("id").getAsString() : j1.has("_id") ? j1.get("_id").getAsString() : null; + String id2 = j2.has("id") ? j2.get("id").getAsString() : j2.has("_id") ? j2.get("_id").getAsString() : null; + if (id1 == null || id2 == null) { + return; + } + + var response = api.panelPost("/v1/panel/tickets/bulk", Map.of( + "ticketIds", List.of(id1, id2) + )); + JsonHelper.assertStatus(response, 200); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + id1, Map.of("status", "closed")); + api.panelPatch("/v1/panel/tickets/" + id2, Map.of("status", "closed")); + } +} + diff --git a/src/test/java/gg/modl/backend/panel/PanelTicketSubscriptionApiTest.java b/src/test/java/gg/modl/backend/panel/PanelTicketSubscriptionApiTest.java index ba622dc..47bc297 100644 --- a/src/test/java/gg/modl/backend/panel/PanelTicketSubscriptionApiTest.java +++ b/src/test/java/gg/modl/backend/panel/PanelTicketSubscriptionApiTest.java @@ -1,53 +1,53 @@ -package gg.modl.backend.panel; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PanelTicketSubscriptionApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void listSubscriptions() throws Exception { - var response = api.panelGet("/v1/panel/ticket-subscriptions"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getUpdates() throws Exception { - var response = api.panelGet("/v1/panel/ticket-subscriptions/updates?limit=5"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void markUpdateAsRead() throws Exception { - var response = api.panelPost("/v1/panel/ticket-subscriptions/updates/nonexistent-id/read", Map.of()); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getAssignedUpdates() throws Exception { - var response = api.panelGet("/v1/panel/ticket-subscriptions/assigned-updates?limit=5"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void unsubscribe() throws Exception { - var response = api.panelDelete("/v1/panel/ticket-subscriptions/nonexistent-ticket-id"); - assertEquals(404, response.statusCode(), "Expected 404 for nonexistent ticket"); - } -} - +package gg.modl.backend.panel; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PanelTicketSubscriptionApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPanelApiAvailable(), StagingCredentials.panelApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void listSubscriptions() throws Exception { + var response = api.panelGet("/v1/panel/ticket-subscriptions"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getUpdates() throws Exception { + var response = api.panelGet("/v1/panel/ticket-subscriptions/updates?limit=5"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void markUpdateAsRead() throws Exception { + var response = api.panelPost("/v1/panel/ticket-subscriptions/updates/nonexistent-id/read", Map.of()); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getAssignedUpdates() throws Exception { + var response = api.panelGet("/v1/panel/ticket-subscriptions/assigned-updates?limit=5"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void unsubscribe() throws Exception { + var response = api.panelDelete("/v1/panel/ticket-subscriptions/nonexistent-ticket-id"); + assertEquals(404, response.statusCode(), "Expected 404 for nonexistent ticket"); + } +} + diff --git a/src/test/java/gg/modl/backend/player/service/MinecraftPlayerServiceTest.java b/src/test/java/gg/modl/backend/player/service/MinecraftPlayerServiceTest.java index d31b3df..5b36ab2 100644 --- a/src/test/java/gg/modl/backend/player/service/MinecraftPlayerServiceTest.java +++ b/src/test/java/gg/modl/backend/player/service/MinecraftPlayerServiceTest.java @@ -1,185 +1,185 @@ -package gg.modl.backend.player.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.player.PlayerService; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.dto.request.AcknowledgeNotificationsRequest; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.settings.service.PunishmentTypeService; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class MinecraftPlayerServiceTest { - - @Mock - private PlayerService playerService; - - @Mock - private PlayerMongoRepository playerRepository; - - @Mock - private TicketMongoRepository ticketRepository; - - @Mock - private PlayerStatusCalculator statusCalculator; - - @Mock - private PunishmentTypeService punishmentTypeService; - - @Mock - private PunishmentLifecycleService punishmentLifecycleService; - - @Mock - private AccountLinkingService accountLinkingService; - - @Mock - private IssuerNameResolver issuerNameResolver; - - @Mock - private StaffMongoRepository staffRepository; - - private MinecraftPlayerService minecraftPlayerService; - - @BeforeEach - void setUp() { - minecraftPlayerService = new MinecraftPlayerService( - playerService, - playerRepository, - ticketRepository, - statusCalculator, - punishmentTypeService, - punishmentLifecycleService, - accountLinkingService, - issuerNameResolver, - staffRepository - ); - } - - @Test - void createNotePersistsThroughRepositorySaveChanges() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Player player = Player.builder() - .minecraftUuid(UUID.randomUUID()) - .build(); - - when(playerRepository.findByMinecraftUuid(server, player.getMinecraftUuid().toString())).thenReturn(Optional.of(player)); - - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote( - server, - player.getMinecraftUuid().toString(), - "Test note", - "Moderator", - null - ); - - assertEquals(org.springframework.http.HttpStatus.OK, response.status()); - verify(playerRepository).replaceNotes(server, player); - assertEquals("Test note", player.getNotes().get(0).getText()); - } - - @Test - void acknowledgeNotificationsRemovesOnlyRequestedNotificationIds() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - UUID playerUuid = UUID.randomUUID(); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .data(new LinkedHashMap<>(Map.of( - "pendingNotifications", new ArrayList<>(List.of( - new LinkedHashMap<>(Map.of("id", "notif-1", "message", "one")), - new LinkedHashMap<>(Map.of("id", "notif-2", "message", "two")) - )) - ))) - .build(); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.acknowledgeNotifications( - server, - new AcknowledgeNotificationsRequest(playerUuid.toString(), List.of("notif-1"), null) - ); - - assertEquals(org.springframework.http.HttpStatus.OK, response.status()); - verify(playerRepository).replacePendingNotifications(server, player, remainingNotifications(player)); - @SuppressWarnings("unchecked") - List> remaining = (List>) player.getData().get("pendingNotifications"); - assertEquals(1, remaining.size()); - assertEquals("notif-2", remaining.get(0).get("id")); - } - - @Test - void getPlayerReportsLowercasesUuidBeforeQueryingTicketRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(ticketRepository.findReportedPlayerTickets(any(Server.class), any(), anyInt())).thenReturn(List.of()); - - minecraftPlayerService.getPlayerReports(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"); - - verify(ticketRepository).findReportedPlayerTickets(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 50); - } - - @Test - void disconnectLowercasesUuidBeforeQueryingPlayerRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - minecraftPlayerService.disconnect(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", 5_000L); - - verify(playerRepository).markDisconnected(eq(server), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), anyLong()); - } - - @Test - void updateServerLowercasesUuidBeforeQueryingPlayerRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - minecraftPlayerService.updateServer(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", "lobby"); - - verify(playerRepository).updateLastServer(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "lobby"); - } - - @Test - void submitIpInfoLowercasesUuidBeforeForwardingToPlayerService() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - minecraftPlayerService.submitIpInfo(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", - "1.2.3.4", "US", "CA", "ASN", false, false); - - verify(playerService).updateIpGeoData(eq(server), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), eq("1.2.3.4"), any()); - } - - @Test - void getPlayerPunishmentsLowercasesUuidBeforeQueryingPlayerRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(playerRepository.findByMinecraftUuid(eq(server), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))) - .thenReturn(Optional.empty()); - - minecraftPlayerService.getPlayerPunishments(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", 1, 10); - - verify(playerRepository).findByMinecraftUuid(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); - } - - @SuppressWarnings("unchecked") - private List> remainingNotifications(Player player) { - return (List>) player.getData().get("pendingNotifications"); - } -} - - +package gg.modl.backend.player.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.player.PlayerService; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.dto.request.AcknowledgeNotificationsRequest; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.settings.service.PunishmentTypeService; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MinecraftPlayerServiceTest { + + @Mock + private PlayerService playerService; + + @Mock + private PlayerMongoRepository playerRepository; + + @Mock + private TicketMongoRepository ticketRepository; + + @Mock + private PlayerStatusCalculator statusCalculator; + + @Mock + private PunishmentTypeService punishmentTypeService; + + @Mock + private PunishmentLifecycleService punishmentLifecycleService; + + @Mock + private AccountLinkingService accountLinkingService; + + @Mock + private IssuerNameResolver issuerNameResolver; + + @Mock + private StaffMongoRepository staffRepository; + + private MinecraftPlayerService minecraftPlayerService; + + @BeforeEach + void setUp() { + minecraftPlayerService = new MinecraftPlayerService( + playerService, + playerRepository, + ticketRepository, + statusCalculator, + punishmentTypeService, + punishmentLifecycleService, + accountLinkingService, + issuerNameResolver, + staffRepository + ); + } + + @Test + void createNotePersistsThroughRepositorySaveChanges() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Player player = Player.builder() + .minecraftUuid(UUID.randomUUID()) + .build(); + + when(playerRepository.findByMinecraftUuid(server, player.getMinecraftUuid().toString())).thenReturn(Optional.of(player)); + + MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote( + server, + player.getMinecraftUuid().toString(), + "Test note", + "Moderator", + null + ); + + assertEquals(org.springframework.http.HttpStatus.OK, response.status()); + verify(playerRepository).replaceNotes(server, player); + assertEquals("Test note", player.getNotes().get(0).getText()); + } + + @Test + void acknowledgeNotificationsRemovesOnlyRequestedNotificationIds() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + UUID playerUuid = UUID.randomUUID(); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .data(new LinkedHashMap<>(Map.of( + "pendingNotifications", new ArrayList<>(List.of( + new LinkedHashMap<>(Map.of("id", "notif-1", "message", "one")), + new LinkedHashMap<>(Map.of("id", "notif-2", "message", "two")) + )) + ))) + .build(); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + + MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.acknowledgeNotifications( + server, + new AcknowledgeNotificationsRequest(playerUuid.toString(), List.of("notif-1"), null) + ); + + assertEquals(org.springframework.http.HttpStatus.OK, response.status()); + verify(playerRepository).replacePendingNotifications(server, player, remainingNotifications(player)); + @SuppressWarnings("unchecked") + List> remaining = (List>) player.getData().get("pendingNotifications"); + assertEquals(1, remaining.size()); + assertEquals("notif-2", remaining.get(0).get("id")); + } + + @Test + void getPlayerReportsLowercasesUuidBeforeQueryingTicketRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(ticketRepository.findReportedPlayerTickets(any(Server.class), any(), anyInt())).thenReturn(List.of()); + + minecraftPlayerService.getPlayerReports(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"); + + verify(ticketRepository).findReportedPlayerTickets(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 50); + } + + @Test + void disconnectLowercasesUuidBeforeQueryingPlayerRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + minecraftPlayerService.disconnect(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", 5_000L); + + verify(playerRepository).markDisconnected(eq(server), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), anyLong()); + } + + @Test + void updateServerLowercasesUuidBeforeQueryingPlayerRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + minecraftPlayerService.updateServer(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", "lobby"); + + verify(playerRepository).updateLastServer(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "lobby"); + } + + @Test + void submitIpInfoLowercasesUuidBeforeForwardingToPlayerService() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + minecraftPlayerService.submitIpInfo(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", + "1.2.3.4", "US", "CA", "ASN", false, false); + + verify(playerService).updateIpGeoData(eq(server), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), eq("1.2.3.4"), any()); + } + + @Test + void getPlayerPunishmentsLowercasesUuidBeforeQueryingPlayerRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(playerRepository.findByMinecraftUuid(eq(server), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"))) + .thenReturn(Optional.empty()); + + minecraftPlayerService.getPlayerPunishments(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", 1, 10); + + verify(playerRepository).findByMinecraftUuid(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + } + + @SuppressWarnings("unchecked") + private List> remainingNotifications(Player player) { + return (List>) player.getData().get("pendingNotifications"); + } +} + + diff --git a/src/test/java/gg/modl/backend/player/service/MinecraftSyncServiceTest.java b/src/test/java/gg/modl/backend/player/service/MinecraftSyncServiceTest.java index 236fd3c..ac27ca8 100644 --- a/src/test/java/gg/modl/backend/player/service/MinecraftSyncServiceTest.java +++ b/src/test/java/gg/modl/backend/player/service/MinecraftSyncServiceTest.java @@ -1,135 +1,135 @@ -package gg.modl.backend.player.service; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.settings.service.PunishmentTypeService; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class MinecraftSyncServiceTest { - - @Mock - private PlayerMongoRepository playerRepository; - - @Mock - private StaffMongoRepository staffRepository; - - @Mock - private ServerMongoRepository serverRepository; - - @Mock - private MigrationMongoRepository migrationRepository; - - @Mock - private ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; - - @Mock - private PlayerStatusCalculator statusCalculator; - - @Mock - private PunishmentTypeService punishmentTypeService; - - @Mock - private PunishmentLifecycleService punishmentLifecycleService; - - @Mock - private MinecraftChatLogService minecraftChatLogService; - - @Mock - private IssuerNameResolver issuerNameResolver; - - @Mock - private SyncStaffEventService syncStaffEventService; - - @Mock - private SyncActiveStaffService syncActiveStaffService; - - private MinecraftSyncService minecraftSyncService; - - @BeforeEach - void setUp() { - minecraftSyncService = new MinecraftSyncService( - playerRepository, - staffRepository, - serverRepository, - migrationRepository, - serverInstanceSnapshotRepository, - statusCalculator, - punishmentTypeService, - punishmentLifecycleService, - minecraftChatLogService, - issuerNameResolver, - syncStaffEventService, - syncActiveStaffService - ); - } - - @SuppressWarnings("unchecked") - @Test - void syncReturnsEnvelopeWhenNoPlayersAreOnline() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of()); - - Map response = minecraftSyncService.sync( - server, - "2025-01-01T00:00:00Z", - List.of(), - "lobby", - List.of(), - List.of(), - null, - null - ); - - assertNotNull(response.get("timestamp")); - assertTrue(response.containsKey("data")); - Map data = (Map) response.get("data"); - assertTrue(data.containsKey("pendingPunishments")); - assertTrue(data.containsKey("staffNotifications")); - } - - @SuppressWarnings("unchecked") - @Test - void syncLowercasesOnlineUuidsBeforeQueryingRepositories() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of()); - when(playerRepository.findByMinecraftUuids(eq(server), any(Collection.class))).thenReturn(List.of()); - - minecraftSyncService.sync( - server, - "2025-01-01T00:00:00Z", - List.of(new MinecraftSyncService.OnlinePlayerInput("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", "PlayerOne", "1.2.3.4")), - "lobby", - List.of(), - List.of(), - null, - null - ); - - ArgumentCaptor> uuidsCaptor = ArgumentCaptor.forClass(Collection.class); - verify(playerRepository).findByMinecraftUuids(eq(server), uuidsCaptor.capture()); - assertTrue(uuidsCaptor.getValue().contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); - } -} +package gg.modl.backend.player.service; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import gg.modl.backend.database.mongo.repository.MigrationMongoRepository; +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.settings.service.PunishmentTypeService; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MinecraftSyncServiceTest { + + @Mock + private PlayerMongoRepository playerRepository; + + @Mock + private StaffMongoRepository staffRepository; + + @Mock + private ServerMongoRepository serverRepository; + + @Mock + private MigrationMongoRepository migrationRepository; + + @Mock + private ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; + + @Mock + private PlayerStatusCalculator statusCalculator; + + @Mock + private PunishmentTypeService punishmentTypeService; + + @Mock + private PunishmentLifecycleService punishmentLifecycleService; + + @Mock + private MinecraftChatLogService minecraftChatLogService; + + @Mock + private IssuerNameResolver issuerNameResolver; + + @Mock + private SyncStaffEventService syncStaffEventService; + + @Mock + private SyncActiveStaffService syncActiveStaffService; + + private MinecraftSyncService minecraftSyncService; + + @BeforeEach + void setUp() { + minecraftSyncService = new MinecraftSyncService( + playerRepository, + staffRepository, + serverRepository, + migrationRepository, + serverInstanceSnapshotRepository, + statusCalculator, + punishmentTypeService, + punishmentLifecycleService, + minecraftChatLogService, + issuerNameResolver, + syncStaffEventService, + syncActiveStaffService + ); + } + + @SuppressWarnings("unchecked") + @Test + void syncReturnsEnvelopeWhenNoPlayersAreOnline() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of()); + + Map response = minecraftSyncService.sync( + server, + "2025-01-01T00:00:00Z", + List.of(), + "lobby", + List.of(), + List.of(), + null, + null + ); + + assertNotNull(response.get("timestamp")); + assertTrue(response.containsKey("data")); + Map data = (Map) response.get("data"); + assertTrue(data.containsKey("pendingPunishments")); + assertTrue(data.containsKey("staffNotifications")); + } + + @SuppressWarnings("unchecked") + @Test + void syncLowercasesOnlineUuidsBeforeQueryingRepositories() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + when(punishmentTypeService.getPunishmentTypes(server)).thenReturn(List.of()); + when(playerRepository.findByMinecraftUuids(eq(server), any(Collection.class))).thenReturn(List.of()); + + minecraftSyncService.sync( + server, + "2025-01-01T00:00:00Z", + List.of(new MinecraftSyncService.OnlinePlayerInput("AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", "PlayerOne", "1.2.3.4")), + "lobby", + List.of(), + List.of(), + null, + null + ); + + ArgumentCaptor> uuidsCaptor = ArgumentCaptor.forClass(Collection.class); + verify(playerRepository).findByMinecraftUuids(eq(server), uuidsCaptor.capture()); + assertTrue(uuidsCaptor.getValue().contains("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")); + } +} diff --git a/src/test/java/gg/modl/backend/player/service/PlayerStatusCalculatorStatWipeTest.java b/src/test/java/gg/modl/backend/player/service/PlayerStatusCalculatorStatWipeTest.java index 4b2733d..d3d68f5 100644 --- a/src/test/java/gg/modl/backend/player/service/PlayerStatusCalculatorStatWipeTest.java +++ b/src/test/java/gg/modl/backend/player/service/PlayerStatusCalculatorStatWipeTest.java @@ -1,242 +1,242 @@ -package gg.modl.backend.player.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; - -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.settings.service.OffenderThresholdSettingsService; -import gg.modl.backend.settings.service.PunishmentTypeService; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class PlayerStatusCalculatorStatWipeTest { - - private PlayerStatusCalculator calculator; - - @BeforeEach - void setUp() { - // isPunishmentNaturallyExpired doesn't use these services - calculator = new PlayerStatusCalculator( - mock(PunishmentTypeService.class), - mock(OffenderThresholdSettingsService.class) - ); - } - - @Test - void expiredPunishmentReturnsTrue() { - // Started 2 hours ago with 1 hour duration -> expired 1 hour ago - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(2, started, 3600_000L, List.of()); - assertTrue(calculator.isPunishmentNaturallyExpired(p)); - } - - private Punishment createPunishment(int typeOrdinal, Date started, long durationMs, List modifications) { - Map data = new HashMap<>(); - data.put("duration", durationMs); - return new Punishment( - "test-id", typeOrdinal, "TestIssuer", null, - new Date(), started, modifications, - List.of(), List.of(), List.of(), data - ); - } - - @Test - void activePunishmentReturnsFalse() { - // Started 30 minutes ago with 1 hour duration -> still active - Date started = new Date(System.currentTimeMillis() - 1800_000L); - Punishment p = createPunishment(2, started, 3600_000L, List.of()); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void notStartedReturnsFalse() { - Punishment p = createPunishment(2, null, 3600_000L, List.of()); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void kickReturnsFalse() { - // Kicks (ordinal 0) should never be considered naturally expired - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(0, started, 3600_000L, List.of()); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void permanentPunishmentReturnsFalse() { - // Permanent punishment (duration -1) never expires naturally - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(2, started, -1L, List.of()); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void zeroDurationPunishmentReturnsFalse() { - // Zero duration treated as permanent - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(2, started, 0L, List.of()); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void pardonedPunishmentReturnsFalse() { - Date started = new Date(System.currentTimeMillis() - 7200_000L); - PunishmentModification pardon = new PunishmentModification( - "mod-1", "MANUAL_PARDON", new Date(), "Staff", null, "", null, null, null - ); - Punishment p = createPunishment(2, started, 3600_000L, List.of(pardon)); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void appealAcceptedReturnsFalse() { - Date started = new Date(System.currentTimeMillis() - 7200_000L); - PunishmentModification appealAccept = new PunishmentModification( - "mod-1", "APPEAL_ACCEPT", new Date(), "Staff", null, "", null, null, null - ); - Punishment p = createPunishment(2, started, 3600_000L, List.of(appealAccept)); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void systemPardonedReturnsFalse() { - Date started = new Date(System.currentTimeMillis() - 7200_000L); - PunishmentModification systemPardon = new PunishmentModification( - "mod-1", "SYSTEM_PARDON", new Date(), "System", null, "", null, null, null - ); - Punishment p = createPunishment(2, started, 3600_000L, List.of(systemPardon)); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void durationChangeExtendsPunishment() { - // Started 2 hours ago with original 1 hour duration, but duration was changed to 4 hours 1 hour ago - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Date modDate = new Date(System.currentTimeMillis() - 3600_000L); - PunishmentModification durationChange = new PunishmentModification( - "mod-1", "MANUAL_DURATION_CHANGE", modDate, "Staff", null, "Extended", - 10800_000L, // 3 hours from modification date -> expires 2 hours from now - null, null - ); - Punishment p = createPunishment(2, started, 3600_000L, List.of(durationChange)); - // Effective expiry = modDate + 3 hours = 2 hours from now -> still active - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void durationChangeShortensPunishmentToExpired() { - // Started 2 hours ago, duration changed 1 hour ago to 30 minutes (expired 30 min ago) - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Date modDate = new Date(System.currentTimeMillis() - 3600_000L); - PunishmentModification durationChange = new PunishmentModification( - "mod-1", "MANUAL_DURATION_CHANGE", modDate, "Staff", null, "Shortened", - 1800_000L, // 30 min from modification date -> expired 30 min ago - null, null - ); - Punishment p = createPunishment(2, started, 86400_000L, List.of(durationChange)); - assertTrue(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void mutePunishmentCanExpireNaturally() { - // Mutes (ordinal 1) should also be eligible for natural expiry - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(1, started, 3600_000L, List.of()); - assertTrue(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void socialPunishmentCanExpireNaturally() { - // Social punishment (ordinal 6 = Chat Abuse) can expire naturally - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(6, started, 3600_000L, List.of()); - assertTrue(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void gameplayPunishmentCanExpireNaturally() { - // Gameplay punishment (ordinal 14 = Cheating) can expire naturally - Date started = new Date(System.currentTimeMillis() - 7200_000L); - Punishment p = createPunishment(14, started, 3600_000L, List.of()); - assertTrue(calculator.isPunishmentNaturallyExpired(p)); - } - - @Test - void makePermanentOverridesEarlierFiniteDurationChange() { - Date started = new Date(System.currentTimeMillis() - 10L * 86400_000L); - Date mod1Date = new Date(System.currentTimeMillis() - 5L * 86400_000L); - Date mod2Date = new Date(System.currentTimeMillis() - 3600_000L); - PunishmentModification finiteChange = new PunishmentModification( - "mod-1", "MANUAL_DURATION_CHANGE", mod1Date, "Staff", null, "Shortened", - 86400_000L, null, null - ); - PunishmentModification makePermanent = new PunishmentModification( - "mod-2", "MANUAL_DURATION_CHANGE", mod2Date, "Staff", null, "Made permanent", - null, null, null - ); - Punishment p = createPunishment(2, started, 86400_000L, List.of(finiteChange, makePermanent)); - assertNull(calculator.getEffectiveExpiry(p)); - assertTrue(calculator.isPunishmentActive(p)); - } - - @Test - void appealDurationChangeRecognizedAsPermanent() { - Date started = new Date(System.currentTimeMillis() - 7200_000L); - PunishmentModification appealChange = new PunishmentModification( - "mod-1", "APPEAL_DURATION_CHANGE", new Date(), "Staff", null, "Appeal made permanent", - null, null, null - ); - Punishment p = createPunishment(2, started, 86400_000L, List.of(appealChange)); - assertNull(calculator.getEffectiveExpiry(p)); - } - - @Test - void makePermanentViaNegativeOnLatest() { - Date started = new Date(System.currentTimeMillis() - 10L * 86400_000L); - Date mod1Date = new Date(System.currentTimeMillis() - 5L * 86400_000L); - Date mod2Date = new Date(System.currentTimeMillis() - 3600_000L); - PunishmentModification finiteChange = new PunishmentModification( - "mod-1", "MANUAL_DURATION_CHANGE", mod1Date, "Staff", null, "Shortened", - 86400_000L, null, null - ); - PunishmentModification makePermanent = new PunishmentModification( - "mod-2", "MANUAL_DURATION_CHANGE", mod2Date, "Staff", null, "Made permanent", - -1L, null, null - ); - Punishment p = createPunishment(2, started, 86400_000L, List.of(finiteChange, makePermanent)); - assertNull(calculator.getEffectiveExpiry(p)); - } - - @Test - void nonDurationModWithEffectiveDurationIgnored() { - Date started = new Date(System.currentTimeMillis() - 1800_000L); - PunishmentModification noteMod = new PunishmentModification( - "mod-1", "NOTE", new Date(), "Staff", null, "", 1L, null, null - ); - Punishment p = createPunishment(2, started, 3600_000L, List.of(noteMod)); - Date expiry = calculator.getEffectiveExpiry(p); - assertNotNull(expiry); - assertEquals(started.getTime() + 3600_000L, expiry.getTime()); - } - - @Test - void nullDataReturnsFalse() { - // Punishment with null data -> getEffectiveExpiry returns null -> permanent -> false - Punishment p = new Punishment( - "test-id", 2, "TestIssuer", null, - new Date(), new Date(System.currentTimeMillis() - 7200_000L), - List.of(), List.of(), List.of(), List.of(), - null - ); - assertFalse(calculator.isPunishmentNaturallyExpired(p)); - } -} +package gg.modl.backend.player.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.settings.service.OffenderThresholdSettingsService; +import gg.modl.backend.settings.service.PunishmentTypeService; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PlayerStatusCalculatorStatWipeTest { + + private PlayerStatusCalculator calculator; + + @BeforeEach + void setUp() { + // isPunishmentNaturallyExpired doesn't use these services + calculator = new PlayerStatusCalculator( + mock(PunishmentTypeService.class), + mock(OffenderThresholdSettingsService.class) + ); + } + + @Test + void expiredPunishmentReturnsTrue() { + // Started 2 hours ago with 1 hour duration -> expired 1 hour ago + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(2, started, 3600_000L, List.of()); + assertTrue(calculator.isPunishmentNaturallyExpired(p)); + } + + private Punishment createPunishment(int typeOrdinal, Date started, long durationMs, List modifications) { + Map data = new HashMap<>(); + data.put("duration", durationMs); + return new Punishment( + "test-id", typeOrdinal, "TestIssuer", null, + new Date(), started, modifications, + List.of(), List.of(), List.of(), data + ); + } + + @Test + void activePunishmentReturnsFalse() { + // Started 30 minutes ago with 1 hour duration -> still active + Date started = new Date(System.currentTimeMillis() - 1800_000L); + Punishment p = createPunishment(2, started, 3600_000L, List.of()); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void notStartedReturnsFalse() { + Punishment p = createPunishment(2, null, 3600_000L, List.of()); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void kickReturnsFalse() { + // Kicks (ordinal 0) should never be considered naturally expired + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(0, started, 3600_000L, List.of()); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void permanentPunishmentReturnsFalse() { + // Permanent punishment (duration -1) never expires naturally + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(2, started, -1L, List.of()); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void zeroDurationPunishmentReturnsFalse() { + // Zero duration treated as permanent + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(2, started, 0L, List.of()); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void pardonedPunishmentReturnsFalse() { + Date started = new Date(System.currentTimeMillis() - 7200_000L); + PunishmentModification pardon = new PunishmentModification( + "mod-1", "MANUAL_PARDON", new Date(), "Staff", null, "", null, null, null + ); + Punishment p = createPunishment(2, started, 3600_000L, List.of(pardon)); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void appealAcceptedReturnsFalse() { + Date started = new Date(System.currentTimeMillis() - 7200_000L); + PunishmentModification appealAccept = new PunishmentModification( + "mod-1", "APPEAL_ACCEPT", new Date(), "Staff", null, "", null, null, null + ); + Punishment p = createPunishment(2, started, 3600_000L, List.of(appealAccept)); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void systemPardonedReturnsFalse() { + Date started = new Date(System.currentTimeMillis() - 7200_000L); + PunishmentModification systemPardon = new PunishmentModification( + "mod-1", "SYSTEM_PARDON", new Date(), "System", null, "", null, null, null + ); + Punishment p = createPunishment(2, started, 3600_000L, List.of(systemPardon)); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void durationChangeExtendsPunishment() { + // Started 2 hours ago with original 1 hour duration, but duration was changed to 4 hours 1 hour ago + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Date modDate = new Date(System.currentTimeMillis() - 3600_000L); + PunishmentModification durationChange = new PunishmentModification( + "mod-1", "MANUAL_DURATION_CHANGE", modDate, "Staff", null, "Extended", + 10800_000L, // 3 hours from modification date -> expires 2 hours from now + null, null + ); + Punishment p = createPunishment(2, started, 3600_000L, List.of(durationChange)); + // Effective expiry = modDate + 3 hours = 2 hours from now -> still active + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void durationChangeShortensPunishmentToExpired() { + // Started 2 hours ago, duration changed 1 hour ago to 30 minutes (expired 30 min ago) + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Date modDate = new Date(System.currentTimeMillis() - 3600_000L); + PunishmentModification durationChange = new PunishmentModification( + "mod-1", "MANUAL_DURATION_CHANGE", modDate, "Staff", null, "Shortened", + 1800_000L, // 30 min from modification date -> expired 30 min ago + null, null + ); + Punishment p = createPunishment(2, started, 86400_000L, List.of(durationChange)); + assertTrue(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void mutePunishmentCanExpireNaturally() { + // Mutes (ordinal 1) should also be eligible for natural expiry + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(1, started, 3600_000L, List.of()); + assertTrue(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void socialPunishmentCanExpireNaturally() { + // Social punishment (ordinal 6 = Chat Abuse) can expire naturally + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(6, started, 3600_000L, List.of()); + assertTrue(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void gameplayPunishmentCanExpireNaturally() { + // Gameplay punishment (ordinal 14 = Cheating) can expire naturally + Date started = new Date(System.currentTimeMillis() - 7200_000L); + Punishment p = createPunishment(14, started, 3600_000L, List.of()); + assertTrue(calculator.isPunishmentNaturallyExpired(p)); + } + + @Test + void makePermanentOverridesEarlierFiniteDurationChange() { + Date started = new Date(System.currentTimeMillis() - 10L * 86400_000L); + Date mod1Date = new Date(System.currentTimeMillis() - 5L * 86400_000L); + Date mod2Date = new Date(System.currentTimeMillis() - 3600_000L); + PunishmentModification finiteChange = new PunishmentModification( + "mod-1", "MANUAL_DURATION_CHANGE", mod1Date, "Staff", null, "Shortened", + 86400_000L, null, null + ); + PunishmentModification makePermanent = new PunishmentModification( + "mod-2", "MANUAL_DURATION_CHANGE", mod2Date, "Staff", null, "Made permanent", + null, null, null + ); + Punishment p = createPunishment(2, started, 86400_000L, List.of(finiteChange, makePermanent)); + assertNull(calculator.getEffectiveExpiry(p)); + assertTrue(calculator.isPunishmentActive(p)); + } + + @Test + void appealDurationChangeRecognizedAsPermanent() { + Date started = new Date(System.currentTimeMillis() - 7200_000L); + PunishmentModification appealChange = new PunishmentModification( + "mod-1", "APPEAL_DURATION_CHANGE", new Date(), "Staff", null, "Appeal made permanent", + null, null, null + ); + Punishment p = createPunishment(2, started, 86400_000L, List.of(appealChange)); + assertNull(calculator.getEffectiveExpiry(p)); + } + + @Test + void makePermanentViaNegativeOnLatest() { + Date started = new Date(System.currentTimeMillis() - 10L * 86400_000L); + Date mod1Date = new Date(System.currentTimeMillis() - 5L * 86400_000L); + Date mod2Date = new Date(System.currentTimeMillis() - 3600_000L); + PunishmentModification finiteChange = new PunishmentModification( + "mod-1", "MANUAL_DURATION_CHANGE", mod1Date, "Staff", null, "Shortened", + 86400_000L, null, null + ); + PunishmentModification makePermanent = new PunishmentModification( + "mod-2", "MANUAL_DURATION_CHANGE", mod2Date, "Staff", null, "Made permanent", + -1L, null, null + ); + Punishment p = createPunishment(2, started, 86400_000L, List.of(finiteChange, makePermanent)); + assertNull(calculator.getEffectiveExpiry(p)); + } + + @Test + void nonDurationModWithEffectiveDurationIgnored() { + Date started = new Date(System.currentTimeMillis() - 1800_000L); + PunishmentModification noteMod = new PunishmentModification( + "mod-1", "NOTE", new Date(), "Staff", null, "", 1L, null, null + ); + Punishment p = createPunishment(2, started, 3600_000L, List.of(noteMod)); + Date expiry = calculator.getEffectiveExpiry(p); + assertNotNull(expiry); + assertEquals(started.getTime() + 3600_000L, expiry.getTime()); + } + + @Test + void nullDataReturnsFalse() { + // Punishment with null data -> getEffectiveExpiry returns null -> permanent -> false + Punishment p = new Punishment( + "test-id", 2, "TestIssuer", null, + new Date(), new Date(System.currentTimeMillis() - 7200_000L), + List.of(), List.of(), List.of(), List.of(), + null + ); + assertFalse(calculator.isPunishmentNaturallyExpired(p)); + } +} diff --git a/src/test/java/gg/modl/backend/player/service/PunishmentServiceTest.java b/src/test/java/gg/modl/backend/player/service/PunishmentServiceTest.java index 6265491..8ce630f 100644 --- a/src/test/java/gg/modl/backend/player/service/PunishmentServiceTest.java +++ b/src/test/java/gg/modl/backend/player/service/PunishmentServiceTest.java @@ -1,413 +1,413 @@ -package gg.modl.backend.player.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.when; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.ticket.service.AppealWorkflowTransitionService; -import gg.modl.backend.ticket.service.TicketService; -import gg.modl.backend.player.data.Player; -import gg.modl.backend.player.data.UsernameEntry; -import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentStatus; -import gg.modl.backend.player.dto.request.CreatePunishmentRequest; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.settings.service.OffenderThresholdSettingsService; -import gg.modl.backend.role.service.PermissionService; -import gg.modl.backend.settings.service.PunishmentTypeService; -import gg.modl.backend.settings.service.WebhookSettingsService; -import gg.modl.backend.log.service.LogService; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class PunishmentServiceTest { - - @Mock - private PlayerMongoRepository playerRepository; - - @Mock - private PunishmentMongoRepository punishmentRepository; - - @Mock - private TicketService ticketService; - - @Mock - private AppealWorkflowTransitionService appealWorkflowTransitionService; - - @Mock - private PlayerStatusCalculator statusCalculator; - - @Mock - private PunishmentTypeService punishmentTypeService; - - @Mock - private OffenderThresholdSettingsService thresholdSettingsService; - - @Mock - private PunishmentDurationCalculator durationCalculator; - - @Mock - private IssuerNameResolver issuerNameResolver; - - @Mock - private StaffMongoRepository staffRepository; - - @Mock - private PunishmentQueryService punishmentQueryService; - - @Mock - private PermissionService permissionService; - - @Mock - private WebhookSettingsService webhookSettingsService; - - @Mock - private PunishmentRealtimePublisher realtimePublisher; - - @Mock - private LogService logService; - - private PunishmentLifecycleService punishmentLifecycleService; - - private PunishmentMutationService punishmentMutationService; - - @BeforeEach - void setUp() { - punishmentLifecycleService = new PunishmentLifecycleService( - playerRepository, - punishmentRepository, - ticketService, - statusCalculator, - punishmentTypeService, - thresholdSettingsService, - durationCalculator, - issuerNameResolver, - staffRepository, - punishmentQueryService, - permissionService, - webhookSettingsService, - realtimePublisher, - logService - ); - punishmentMutationService = new PunishmentMutationService( - playerRepository, - punishmentRepository, - ticketService, - appealWorkflowTransitionService, - issuerNameResolver, - staffRepository, - punishmentQueryService, - punishmentLifecycleService, - realtimePublisher - ); - } - - @Test - void acknowledgePunishmentStartsPunishmentAndClearsQueuedStatus() { - UUID playerUuid = UUID.randomUUID(); - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Punishment punishment = new Punishment( - "punish-1", - 1, - "Mod", - null, - new Date(), - null, - List.of(), - List.of(), - List.of(), - List.of(), - new HashMap<>(Map.of("status", "Unstarted")) - ); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .punishments(new ArrayList<>(List.of(punishment))) - .build(); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - when(punishmentRepository.acknowledgePunishmentStart(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(Date.class))) - .thenReturn(true); - - PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( - server, - playerUuid, - "punish-1" - ); - - assertEquals(PunishmentOperationStatus.SUCCESS, result.status()); - assertEquals("Punishment acknowledged", result.message()); - verify(punishmentRepository).acknowledgePunishmentStart(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(Date.class)); - } - - @Test - void acknowledgePunishmentReturnsNoOpWhenRepositoryReportsNoChange() { - UUID playerUuid = UUID.randomUUID(); - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Punishment punishment = new Punishment( - "punish-1", - 1, - "Mod", - null, - new Date(), - new Date(), - List.of(), - List.of(), - List.of(), - List.of(), - new HashMap<>() - ); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .punishments(new ArrayList<>(List.of(punishment))) - .build(); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - when(punishmentRepository.acknowledgePunishmentStart(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(Date.class))) - .thenReturn(false); - - PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( - server, - playerUuid, - "punish-1" - ); - - assertEquals(PunishmentOperationStatus.NO_OP, result.status()); - assertEquals("Punishment already acknowledged", result.message()); - } - - @Test - void toggleOptionRejectsUnknownOption() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - PunishmentOperationResult result = punishmentMutationService.toggleOption( - server, - "punish-1", - "UNKNOWN_OPTION", - true, - "Mod", - null - ); - - assertEquals(PunishmentOperationStatus.INVALID_REQUEST, result.status()); - assertEquals("Invalid option", result.message()); - } - - @Test - void createPunishmentMutatesPlayerAggregateAndPersistsThroughRepository() { - UUID playerUuid = UUID.randomUUID(); - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .usernames(new ArrayList<>(List.of(new UsernameEntry("CurrentName", new Date())))) - .punishments(new ArrayList<>()) - .data(new HashMap<>(Map.of("lastSkinHash", "skin-hash"))) - .build(); - - CreatePunishmentRequest request = new CreatePunishmentRequest( - "Mod", - null, - 4, - null, - null, - null, - null, - null, - new HashMap<>(Map.of("status", "Queued")), - "Reason text", - null - ); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - when(issuerNameResolver.resolve(any(), any(), any(Server.class))).thenReturn("Mod"); - - String punishmentId = punishmentLifecycleService.createPunishment(server, playerUuid, request); - - assertNotNull(punishmentId); - verify(punishmentRepository).appendPunishment(eq(server), eq(playerUuid.toString()), any(Punishment.class)); - assertEquals(1, player.getPunishments().size()); - Punishment createdPunishment = player.getPunishments().get(0); - assertEquals(punishmentId, createdPunishment.getId()); - assertEquals("Reason text", createdPunishment.getData().get("reason")); - assertEquals("Queued", createdPunishment.getData().get("status")); - } - - @Test - void createPunishmentPersistsInternalOffenseLevelThatSurvivesStatusWipe() { - UUID playerUuid = UUID.randomUUID(); - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .usernames(new ArrayList<>(List.of(new UsernameEntry("CurrentName", new Date())))) - .punishments(new ArrayList<>()) - .data(new HashMap<>()) - .build(); - - CreatePunishmentRequest request = new CreatePunishmentRequest( - "Mod", null, 6, null, null, null, - "regular", null, new HashMap<>(), "Reason text", null - ); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - when(issuerNameResolver.resolve(any(), any(), any(Server.class))).thenReturn("Mod"); - when(durationCalculator.calculate(eq(server), any(), eq(6), eq("regular"))) - .thenReturn(new PunishmentDurationCalculator.DurationResult(3600_000L, "habitual", "habitual")); - - punishmentLifecycleService.createPunishment(server, playerUuid, request); - - Punishment created = player.getPunishments().get(0); - assertEquals("habitual", created.getData().get("offenseLevel")); - - created.getData().remove("status"); - assertEquals("habitual", created.getData().get("offenseLevel")); - } - - @Test - void createMinecraftPunishmentForcesUnstartedForNonStackingPluginPunishment() { - UUID playerUuid = UUID.randomUUID(); - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .usernames(new ArrayList<>(List.of(new UsernameEntry("CurrentName", new Date())))) - .punishments(new ArrayList<>()) - .data(new HashMap<>()) - .build(); - - CreatePunishmentRequest request = new CreatePunishmentRequest( - "Mod", null, 2, null, null, null, null, null, - new HashMap<>(Map.of("pendingAcknowledgement", true)), "Reason text", null - ); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - when(issuerNameResolver.resolve(any(), any(), any(Server.class))).thenReturn("Mod"); - - punishmentLifecycleService.createPunishment(server, playerUuid, request); - - Punishment created = player.getPunishments().get(0); - assertEquals("Unstarted", created.getData().get("status")); - assertNull(created.getStarted()); - } - - @Test - void systemPardonPunishmentAddsSystemPardonThroughRepositorySave() { - UUID playerUuid = UUID.randomUUID(); - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Punishment punishment = new Punishment( - "punish-1", - 1, - "Mod", - null, - new Date(), - new Date(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new HashMap<>() - ); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .punishments(new ArrayList<>(List.of(punishment))) - .build(); - - when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); - - punishmentLifecycleService.systemPardonPunishment(server, playerUuid, "punish-1", "Auto-pardoned"); - - verify(punishmentRepository).appendPardon(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(PunishmentModification.class), anyList(), eq(PunishmentStatus.PARDONED)); - Punishment updatedPunishment = player.getPunishments().get(0); - assertEquals(1, updatedPunishment.getModifications().size()); - PunishmentModification modification = updatedPunishment.getModifications().get(0); - assertEquals("SYSTEM_PARDON", modification.type()); - assertEquals("Auto-pardoned", modification.reason()); - assertEquals("Auto-pardoned", updatedPunishment.getNotes().get(0).text()); - assertEquals("Pardoned", updatedPunishment.getData().get("status")); - } - - @Test - void cascadePardonLinkedBansUsesDatabaseScopedRepositorySaves() { - UUID playerUuid = UUID.randomUUID(); - Punishment linkedBan = new Punishment( - "linked-1", - 4, - "System", - null, - new Date(), - new Date(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new HashMap<>(Map.of("linkedBanId", "parent-1")) - ); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .punishments(new ArrayList<>(List.of(linkedBan))) - .build(); - - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(punishmentRepository.findByLinkedBanId(server, "parent-1")).thenReturn(List.of(player)); - when(statusCalculator.isPunishmentActive(linkedBan)).thenReturn(true); - - int updatedCount = punishmentLifecycleService.cascadePardonLinkedBans(server, "parent-1"); - - assertEquals(1, updatedCount); - verify(punishmentRepository).appendPardon(eq(server), eq(playerUuid.toString()), eq("linked-1"), any(PunishmentModification.class), anyList(), eq(PunishmentStatus.PARDONED)); - Punishment updatedPunishment = player.getPunishments().get(0); - assertEquals("SYSTEM_PARDON", updatedPunishment.getModifications().get(0).type()); - } - - @Test - void cascadePardonLinkedBansSkipsInactiveLinkedBan() { - UUID playerUuid = UUID.randomUUID(); - Punishment linkedBan = new Punishment( - "linked-1", - 4, - "System", - null, - new Date(), - new Date(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new ArrayList<>(), - new HashMap<>(Map.of("linkedBanId", "parent-1")) - ); - Player player = Player.builder() - .minecraftUuid(playerUuid) - .punishments(new ArrayList<>(List.of(linkedBan))) - .build(); - - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(punishmentRepository.findByLinkedBanId(server, "parent-1")).thenReturn(List.of(player)); - when(statusCalculator.isPunishmentActive(linkedBan)).thenReturn(false); - - int updatedCount = punishmentLifecycleService.cascadePardonLinkedBans(server, "parent-1"); - - assertEquals(0, updatedCount); - verify(punishmentRepository, never()).appendPardon(any(), any(), any(), any(), any(), any()); - } -} +package gg.modl.backend.player.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.ticket.service.AppealWorkflowTransitionService; +import gg.modl.backend.ticket.service.TicketService; +import gg.modl.backend.player.data.Player; +import gg.modl.backend.player.data.UsernameEntry; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import gg.modl.backend.player.data.punishment.PunishmentStatus; +import gg.modl.backend.player.dto.request.CreatePunishmentRequest; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.settings.service.OffenderThresholdSettingsService; +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.settings.service.WebhookSettingsService; +import gg.modl.backend.log.service.LogService; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PunishmentServiceTest { + + @Mock + private PlayerMongoRepository playerRepository; + + @Mock + private PunishmentMongoRepository punishmentRepository; + + @Mock + private TicketService ticketService; + + @Mock + private AppealWorkflowTransitionService appealWorkflowTransitionService; + + @Mock + private PlayerStatusCalculator statusCalculator; + + @Mock + private PunishmentTypeService punishmentTypeService; + + @Mock + private OffenderThresholdSettingsService thresholdSettingsService; + + @Mock + private PunishmentDurationCalculator durationCalculator; + + @Mock + private IssuerNameResolver issuerNameResolver; + + @Mock + private StaffMongoRepository staffRepository; + + @Mock + private PunishmentQueryService punishmentQueryService; + + @Mock + private PermissionService permissionService; + + @Mock + private WebhookSettingsService webhookSettingsService; + + @Mock + private PunishmentRealtimePublisher realtimePublisher; + + @Mock + private LogService logService; + + private PunishmentLifecycleService punishmentLifecycleService; + + private PunishmentMutationService punishmentMutationService; + + @BeforeEach + void setUp() { + punishmentLifecycleService = new PunishmentLifecycleService( + playerRepository, + punishmentRepository, + ticketService, + statusCalculator, + punishmentTypeService, + thresholdSettingsService, + durationCalculator, + issuerNameResolver, + staffRepository, + punishmentQueryService, + permissionService, + webhookSettingsService, + realtimePublisher, + logService + ); + punishmentMutationService = new PunishmentMutationService( + playerRepository, + punishmentRepository, + ticketService, + appealWorkflowTransitionService, + issuerNameResolver, + staffRepository, + punishmentQueryService, + punishmentLifecycleService, + realtimePublisher + ); + } + + @Test + void acknowledgePunishmentStartsPunishmentAndClearsQueuedStatus() { + UUID playerUuid = UUID.randomUUID(); + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Punishment punishment = new Punishment( + "punish-1", + 1, + "Mod", + null, + new Date(), + null, + List.of(), + List.of(), + List.of(), + List.of(), + new HashMap<>(Map.of("status", "Unstarted")) + ); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .punishments(new ArrayList<>(List.of(punishment))) + .build(); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + when(punishmentRepository.acknowledgePunishmentStart(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(Date.class))) + .thenReturn(true); + + PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( + server, + playerUuid, + "punish-1" + ); + + assertEquals(PunishmentOperationStatus.SUCCESS, result.status()); + assertEquals("Punishment acknowledged", result.message()); + verify(punishmentRepository).acknowledgePunishmentStart(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(Date.class)); + } + + @Test + void acknowledgePunishmentReturnsNoOpWhenRepositoryReportsNoChange() { + UUID playerUuid = UUID.randomUUID(); + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Punishment punishment = new Punishment( + "punish-1", + 1, + "Mod", + null, + new Date(), + new Date(), + List.of(), + List.of(), + List.of(), + List.of(), + new HashMap<>() + ); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .punishments(new ArrayList<>(List.of(punishment))) + .build(); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + when(punishmentRepository.acknowledgePunishmentStart(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(Date.class))) + .thenReturn(false); + + PunishmentOperationResult result = punishmentLifecycleService.acknowledgePunishment( + server, + playerUuid, + "punish-1" + ); + + assertEquals(PunishmentOperationStatus.NO_OP, result.status()); + assertEquals("Punishment already acknowledged", result.message()); + } + + @Test + void toggleOptionRejectsUnknownOption() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + PunishmentOperationResult result = punishmentMutationService.toggleOption( + server, + "punish-1", + "UNKNOWN_OPTION", + true, + "Mod", + null + ); + + assertEquals(PunishmentOperationStatus.INVALID_REQUEST, result.status()); + assertEquals("Invalid option", result.message()); + } + + @Test + void createPunishmentMutatesPlayerAggregateAndPersistsThroughRepository() { + UUID playerUuid = UUID.randomUUID(); + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .usernames(new ArrayList<>(List.of(new UsernameEntry("CurrentName", new Date())))) + .punishments(new ArrayList<>()) + .data(new HashMap<>(Map.of("lastSkinHash", "skin-hash"))) + .build(); + + CreatePunishmentRequest request = new CreatePunishmentRequest( + "Mod", + null, + 4, + null, + null, + null, + null, + null, + new HashMap<>(Map.of("status", "Queued")), + "Reason text", + null + ); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + when(issuerNameResolver.resolve(any(), any(), any(Server.class))).thenReturn("Mod"); + + String punishmentId = punishmentLifecycleService.createPunishment(server, playerUuid, request); + + assertNotNull(punishmentId); + verify(punishmentRepository).appendPunishment(eq(server), eq(playerUuid.toString()), any(Punishment.class)); + assertEquals(1, player.getPunishments().size()); + Punishment createdPunishment = player.getPunishments().get(0); + assertEquals(punishmentId, createdPunishment.getId()); + assertEquals("Reason text", createdPunishment.getData().get("reason")); + assertEquals("Queued", createdPunishment.getData().get("status")); + } + + @Test + void createPunishmentPersistsInternalOffenseLevelThatSurvivesStatusWipe() { + UUID playerUuid = UUID.randomUUID(); + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .usernames(new ArrayList<>(List.of(new UsernameEntry("CurrentName", new Date())))) + .punishments(new ArrayList<>()) + .data(new HashMap<>()) + .build(); + + CreatePunishmentRequest request = new CreatePunishmentRequest( + "Mod", null, 6, null, null, null, + "regular", null, new HashMap<>(), "Reason text", null + ); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + when(issuerNameResolver.resolve(any(), any(), any(Server.class))).thenReturn("Mod"); + when(durationCalculator.calculate(eq(server), any(), eq(6), eq("regular"))) + .thenReturn(new PunishmentDurationCalculator.DurationResult(3600_000L, "habitual", "habitual")); + + punishmentLifecycleService.createPunishment(server, playerUuid, request); + + Punishment created = player.getPunishments().get(0); + assertEquals("habitual", created.getData().get("offenseLevel")); + + created.getData().remove("status"); + assertEquals("habitual", created.getData().get("offenseLevel")); + } + + @Test + void createMinecraftPunishmentForcesUnstartedForNonStackingPluginPunishment() { + UUID playerUuid = UUID.randomUUID(); + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .usernames(new ArrayList<>(List.of(new UsernameEntry("CurrentName", new Date())))) + .punishments(new ArrayList<>()) + .data(new HashMap<>()) + .build(); + + CreatePunishmentRequest request = new CreatePunishmentRequest( + "Mod", null, 2, null, null, null, null, null, + new HashMap<>(Map.of("pendingAcknowledgement", true)), "Reason text", null + ); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + when(issuerNameResolver.resolve(any(), any(), any(Server.class))).thenReturn("Mod"); + + punishmentLifecycleService.createPunishment(server, playerUuid, request); + + Punishment created = player.getPunishments().get(0); + assertEquals("Unstarted", created.getData().get("status")); + assertNull(created.getStarted()); + } + + @Test + void systemPardonPunishmentAddsSystemPardonThroughRepositorySave() { + UUID playerUuid = UUID.randomUUID(); + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Punishment punishment = new Punishment( + "punish-1", + 1, + "Mod", + null, + new Date(), + new Date(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new HashMap<>() + ); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .punishments(new ArrayList<>(List.of(punishment))) + .build(); + + when(playerRepository.findByMinecraftUuid(server, playerUuid.toString())).thenReturn(Optional.of(player)); + + punishmentLifecycleService.systemPardonPunishment(server, playerUuid, "punish-1", "Auto-pardoned"); + + verify(punishmentRepository).appendPardon(eq(server), eq(playerUuid.toString()), eq("punish-1"), any(PunishmentModification.class), anyList(), eq(PunishmentStatus.PARDONED)); + Punishment updatedPunishment = player.getPunishments().get(0); + assertEquals(1, updatedPunishment.getModifications().size()); + PunishmentModification modification = updatedPunishment.getModifications().get(0); + assertEquals("SYSTEM_PARDON", modification.type()); + assertEquals("Auto-pardoned", modification.reason()); + assertEquals("Auto-pardoned", updatedPunishment.getNotes().get(0).text()); + assertEquals("Pardoned", updatedPunishment.getData().get("status")); + } + + @Test + void cascadePardonLinkedBansUsesDatabaseScopedRepositorySaves() { + UUID playerUuid = UUID.randomUUID(); + Punishment linkedBan = new Punishment( + "linked-1", + 4, + "System", + null, + new Date(), + new Date(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new HashMap<>(Map.of("linkedBanId", "parent-1")) + ); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .punishments(new ArrayList<>(List.of(linkedBan))) + .build(); + + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(punishmentRepository.findByLinkedBanId(server, "parent-1")).thenReturn(List.of(player)); + when(statusCalculator.isPunishmentActive(linkedBan)).thenReturn(true); + + int updatedCount = punishmentLifecycleService.cascadePardonLinkedBans(server, "parent-1"); + + assertEquals(1, updatedCount); + verify(punishmentRepository).appendPardon(eq(server), eq(playerUuid.toString()), eq("linked-1"), any(PunishmentModification.class), anyList(), eq(PunishmentStatus.PARDONED)); + Punishment updatedPunishment = player.getPunishments().get(0); + assertEquals("SYSTEM_PARDON", updatedPunishment.getModifications().get(0).type()); + } + + @Test + void cascadePardonLinkedBansSkipsInactiveLinkedBan() { + UUID playerUuid = UUID.randomUUID(); + Punishment linkedBan = new Punishment( + "linked-1", + 4, + "System", + null, + new Date(), + new Date(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new ArrayList<>(), + new HashMap<>(Map.of("linkedBanId", "parent-1")) + ); + Player player = Player.builder() + .minecraftUuid(playerUuid) + .punishments(new ArrayList<>(List.of(linkedBan))) + .build(); + + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(punishmentRepository.findByLinkedBanId(server, "parent-1")).thenReturn(List.of(player)); + when(statusCalculator.isPunishmentActive(linkedBan)).thenReturn(false); + + int updatedCount = punishmentLifecycleService.cascadePardonLinkedBans(server, "parent-1"); + + assertEquals(0, updatedCount); + verify(punishmentRepository, never()).appendPardon(any(), any(), any(), any(), any(), any()); + } +} diff --git a/src/test/java/gg/modl/backend/public_api/EvidenceUploadApiTest.java b/src/test/java/gg/modl/backend/public_api/EvidenceUploadApiTest.java index 9b19d7c..3789eee 100644 --- a/src/test/java/gg/modl/backend/public_api/EvidenceUploadApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/EvidenceUploadApiTest.java @@ -1,110 +1,110 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class EvidenceUploadApiTest { - - static ApiClient api; - - private static String testUuid; - private static int testTypeOrdinal; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - - testUuid = TestDataProvider.getPlayers().get(0).uuid(); - testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); - } - - @Test - void validateToken() throws Exception { - // Invalid token should return 404 - var response = api.publicGet("/v1/public/evidence-upload/invalid-token"); - assertEquals(404, response.statusCode()); - } - - @Test - void validateTokenWithRealToken() throws Exception { - // Create a punishment and get an upload token - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "Evidence upload test", - "duration", 300, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var tokenResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/upload-token", Map.of( - "issuerName", "TestBot" - )); - if (tokenResponse.statusCode() != 200) { - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", "reason", "cleanup" - )); - return; - } - String token = JsonHelper.parseObject(tokenResponse.body()).get("token").getAsString(); - - // Validate the token - var response = api.publicGet("/v1/public/evidence-upload/" + token); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("punishmentId")); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", "reason", "cleanup" - )); - } - - @Test - void presignUpload() throws Exception { - var response = api.publicPost("/v1/public/evidence-upload/invalid-token/presign", Map.of( - "fileName", "test.png", - "contentType", "image/png", - "fileSize", 1024 - )); - assertEquals(404, response.statusCode()); - } - - @Test - void confirmUpload() throws Exception { - var response = api.publicPost("/v1/public/evidence-upload/invalid-token/confirm", Map.of( - "key", "nonexistent-key" - )); - assertEquals(404, response.statusCode()); - } - - @Test - void submitEvidence() throws Exception { - var response = api.publicPost("/v1/public/evidence-upload/invalid-token/submit", Map.of( - "evidence", List.of(Map.of( - "url", "https://example.com/evidence.png", - "fileName", "evidence.png", - "fileType", "image/png", - "fileSize", 1024 - )) - )); - assertEquals(404, response.statusCode()); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class EvidenceUploadApiTest { + + static ApiClient api; + + private static String testUuid; + private static int testTypeOrdinal; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + + testUuid = TestDataProvider.getPlayers().get(0).uuid(); + testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); + } + + @Test + void validateToken() throws Exception { + // Invalid token should return 404 + var response = api.publicGet("/v1/public/evidence-upload/invalid-token"); + assertEquals(404, response.statusCode()); + } + + @Test + void validateTokenWithRealToken() throws Exception { + // Create a punishment and get an upload token + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "Evidence upload test", + "duration", 300, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var tokenResponse = api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/upload-token", Map.of( + "issuerName", "TestBot" + )); + if (tokenResponse.statusCode() != 200) { + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", "reason", "cleanup" + )); + return; + } + String token = JsonHelper.parseObject(tokenResponse.body()).get("token").getAsString(); + + // Validate the token + var response = api.publicGet("/v1/public/evidence-upload/" + token); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("punishmentId")); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", "reason", "cleanup" + )); + } + + @Test + void presignUpload() throws Exception { + var response = api.publicPost("/v1/public/evidence-upload/invalid-token/presign", Map.of( + "fileName", "test.png", + "contentType", "image/png", + "fileSize", 1024 + )); + assertEquals(404, response.statusCode()); + } + + @Test + void confirmUpload() throws Exception { + var response = api.publicPost("/v1/public/evidence-upload/invalid-token/confirm", Map.of( + "key", "nonexistent-key" + )); + assertEquals(404, response.statusCode()); + } + + @Test + void submitEvidence() throws Exception { + var response = api.publicPost("/v1/public/evidence-upload/invalid-token/submit", Map.of( + "evidence", List.of(Map.of( + "url", "https://example.com/evidence.png", + "fileName", "evidence.png", + "fileType", "image/png", + "fileSize", 1024 + )) + )); + assertEquals(404, response.statusCode()); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicAppealApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicAppealApiTest.java index b254920..1bbb360 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicAppealApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicAppealApiTest.java @@ -1,108 +1,108 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDatabase; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicAppealApiTest { - - static ApiClient api; - - private static String testUuid; - private static int testTypeOrdinal; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - - testUuid = TestDataProvider.getPlayers().get(0).uuid(); - testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); - } - - @Test - void getAppeal() throws Exception { - var response = api.publicGet("/v1/public/appeals/nonexistent-appeal-id"); - assertEquals(404, response.statusCode()); - } - - @Test - void createAndGetAppeal() throws Exception { - // Need an active punishment first - var createPunishment = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", testUuid, - "issuerName", "TestBot", - "type_ordinal", testTypeOrdinal, - "reason", "Public appeal test", - "duration", 300, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createPunishment.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createPunishment.body()).get("punishmentId").getAsString(); - - // Create appeal - var createAppeal = api.publicPost("/v1/public/appeals", Map.of( - "punishmentId", punishmentId, - "playerUuid", testUuid, - "email", "test@example.com", - "reason", "API test appeal - auto cleanup" - )); - int appealStatus = createAppeal.statusCode(); - assertTrue(appealStatus == 200 || appealStatus == 201, "Expected 200 or 201 but got " + appealStatus); - - if (appealStatus == 200 || appealStatus == 201) { - var json = JsonHelper.parseObject(createAppeal.body()); - String appealId = json.has("appealId") ? json.get("appealId").getAsString() : null; - if (appealId != null) { - // DB VERIFICATION: confirm appeal created as ticket with type=appeal - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(appealId); - assertNotNull(dbTicket, "Appeal should exist in DB (tickets collection) after creation"); - assertEquals("appeal", dbTicket.getString("type")); - } - - var unverifiedGet = api.publicGet("/v1/public/appeals/" + appealId); - assertEquals(403, unverifiedGet.statusCode()); - var unverifiedGetJson = JsonHelper.parseObject(unverifiedGet.body()); - assertTrue(unverifiedGetJson.get("requiresVerification").getAsBoolean()); - - var invalidCodeVerify = api.publicPost("/v1/public/appeals/" + appealId + "/verify", Map.of("code", "000000")); - assertEquals(403, invalidCodeVerify.statusCode()); - - // Cleanup: dismiss via panel - api.panelPatch("/v1/panel/appeals/" + appealId + "/status", Map.of("status", "dismissed")); - } - } - - // Cleanup punishment - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } - - @Test - void replyToAppeal() throws Exception { - var response = api.publicPost("/v1/public/appeals/nonexistent-appeal-id/replies", Map.of( - "name", "PublicUser", - "content", "Test reply", - "type", "player", - "staff", false - )); - assertEquals(404, response.statusCode()); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDatabase; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicAppealApiTest { + + static ApiClient api; + + private static String testUuid; + private static int testTypeOrdinal; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + + testUuid = TestDataProvider.getPlayers().get(0).uuid(); + testTypeOrdinal = TestDataProvider.getPunishmentTypes().get(0).ordinal(); + } + + @Test + void getAppeal() throws Exception { + var response = api.publicGet("/v1/public/appeals/nonexistent-appeal-id"); + assertEquals(404, response.statusCode()); + } + + @Test + void createAndGetAppeal() throws Exception { + // Need an active punishment first + var createPunishment = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", testUuid, + "issuerName", "TestBot", + "type_ordinal", testTypeOrdinal, + "reason", "Public appeal test", + "duration", 300, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createPunishment.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createPunishment.body()).get("punishmentId").getAsString(); + + // Create appeal + var createAppeal = api.publicPost("/v1/public/appeals", Map.of( + "punishmentId", punishmentId, + "playerUuid", testUuid, + "email", "test@example.com", + "reason", "API test appeal - auto cleanup" + )); + int appealStatus = createAppeal.statusCode(); + assertTrue(appealStatus == 200 || appealStatus == 201, "Expected 200 or 201 but got " + appealStatus); + + if (appealStatus == 200 || appealStatus == 201) { + var json = JsonHelper.parseObject(createAppeal.body()); + String appealId = json.has("appealId") ? json.get("appealId").getAsString() : null; + if (appealId != null) { + // DB VERIFICATION: confirm appeal created as ticket with type=appeal + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(appealId); + assertNotNull(dbTicket, "Appeal should exist in DB (tickets collection) after creation"); + assertEquals("appeal", dbTicket.getString("type")); + } + + var unverifiedGet = api.publicGet("/v1/public/appeals/" + appealId); + assertEquals(403, unverifiedGet.statusCode()); + var unverifiedGetJson = JsonHelper.parseObject(unverifiedGet.body()); + assertTrue(unverifiedGetJson.get("requiresVerification").getAsBoolean()); + + var invalidCodeVerify = api.publicPost("/v1/public/appeals/" + appealId + "/verify", Map.of("code", "000000")); + assertEquals(403, invalidCodeVerify.statusCode()); + + // Cleanup: dismiss via panel + api.panelPatch("/v1/panel/appeals/" + appealId + "/status", Map.of("status", "dismissed")); + } + } + + // Cleanup punishment + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } + + @Test + void replyToAppeal() throws Exception { + var response = api.publicPost("/v1/public/appeals/nonexistent-appeal-id/replies", Map.of( + "name", "PublicUser", + "content", "Test reply", + "type", "player", + "staff", false + )); + assertEquals(404, response.statusCode()); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicHomepageCardApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicHomepageCardApiTest.java index 436c947..160e458 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicHomepageCardApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicHomepageCardApiTest.java @@ -1,26 +1,26 @@ -package gg.modl.backend.public_api; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicHomepageCardApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getVisibleCards() throws Exception { - var response = api.publicGet("/v1/public/homepage-cards"); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.public_api; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicHomepageCardApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getVisibleCards() throws Exception { + var response = api.publicGet("/v1/public/homepage-cards"); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicKnowledgebaseApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicKnowledgebaseApiTest.java index 498b1d8..b1a07a8 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicKnowledgebaseApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicKnowledgebaseApiTest.java @@ -1,61 +1,61 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicKnowledgebaseApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getCategories() throws Exception { - var response = api.publicGet("/v1/public/knowledgebase/categories"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getArticlesByCategory() throws Exception { - // Get a category ID first - var catResponse = api.publicGet("/v1/public/knowledgebase/categories"); - var arr = JsonHelper.parseArray(catResponse.body()); - if (arr.isEmpty()) { - return; - } - - var cat = arr.get(0).getAsJsonObject(); - String categoryId = cat.has("id") ? cat.get("id").getAsString() : - cat.has("_id") ? cat.get("_id").getAsString() : null; - if (categoryId == null) { - return; - } - - var response = api.publicGet("/v1/public/knowledgebase/categories/" + categoryId + "/articles"); - JsonHelper.assertStatus(response, 200); - } - - @Test - void getArticleByIdOrSlug() throws Exception { - // Try with a nonexistent slug - var response = api.publicGet("/v1/public/knowledgebase/articles/nonexistent-slug"); - assertEquals(404, response.statusCode()); - } - - @Test - void searchArticles() throws Exception { - var response = api.publicGet("/v1/public/knowledgebase/search?q=test"); - JsonHelper.assertStatus(response, 200); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicKnowledgebaseApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getCategories() throws Exception { + var response = api.publicGet("/v1/public/knowledgebase/categories"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getArticlesByCategory() throws Exception { + // Get a category ID first + var catResponse = api.publicGet("/v1/public/knowledgebase/categories"); + var arr = JsonHelper.parseArray(catResponse.body()); + if (arr.isEmpty()) { + return; + } + + var cat = arr.get(0).getAsJsonObject(); + String categoryId = cat.has("id") ? cat.get("id").getAsString() : + cat.has("_id") ? cat.get("_id").getAsString() : null; + if (categoryId == null) { + return; + } + + var response = api.publicGet("/v1/public/knowledgebase/categories/" + categoryId + "/articles"); + JsonHelper.assertStatus(response, 200); + } + + @Test + void getArticleByIdOrSlug() throws Exception { + // Try with a nonexistent slug + var response = api.publicGet("/v1/public/knowledgebase/articles/nonexistent-slug"); + assertEquals(404, response.statusCode()); + } + + @Test + void searchArticles() throws Exception { + var response = api.publicGet("/v1/public/knowledgebase/search?q=test"); + JsonHelper.assertStatus(response, 200); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicMediaApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicMediaApiTest.java index 9efe2a0..b55e27e 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicMediaApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicMediaApiTest.java @@ -1,52 +1,52 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicMediaApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getConfig() throws Exception { - var response = api.publicGet("/v1/public/media/config"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("backblazeConfigured") || json.has("supportedTypes")); - } - - @Test - void presignUpload() throws Exception { - var response = api.publicPost("/v1/public/media/presign", Map.of( - "fileName", "test-image.png", - "contentType", "image/png", - "fileSize", 1024, - "uploadType", "ticket", - "entityId", "new" - )); - JsonHelper.assertStatus(response, 200); - } - - @Test - void confirmUpload() throws Exception { - var response = api.publicPost("/v1/public/media/confirm", Map.of( - "key", "nonexistent-key" - )); - assertEquals(403, response.statusCode()); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicMediaApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getConfig() throws Exception { + var response = api.publicGet("/v1/public/media/config"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("backblazeConfigured") || json.has("supportedTypes")); + } + + @Test + void presignUpload() throws Exception { + var response = api.publicPost("/v1/public/media/presign", Map.of( + "fileName", "test-image.png", + "contentType", "image/png", + "fileSize", 1024, + "uploadType", "ticket", + "entityId", "new" + )); + JsonHelper.assertStatus(response, 200); + } + + @Test + void confirmUpload() throws Exception { + var response = api.publicPost("/v1/public/media/confirm", Map.of( + "key", "nonexistent-key" + )); + assertEquals(403, response.statusCode()); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicPlayerApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicPlayerApiTest.java index 4e5034e..7152640 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicPlayerApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicPlayerApiTest.java @@ -1,31 +1,31 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicPlayerApiTest { - - static ApiClient api; - - private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getAvatarProxy() throws Exception { - var response = api.publicGet("/v1/public/players/avatar/" + TEST_UUID + "?size=32&overlay=true"); - int status = response.statusCode(); - // 200 = image returned, 302 = redirect to avatar source - assertTrue(status == 200 || status == 302, "Expected 200 or 302 but got " + status); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicPlayerApiTest { + + static ApiClient api; + + private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getAvatarProxy() throws Exception { + var response = api.publicGet("/v1/public/players/avatar/" + TEST_UUID + "?size=32&overlay=true"); + int status = response.statusCode(); + // 200 = image returned, 302 = redirect to avatar source + assertTrue(status == 200 || status == 302, "Expected 200 or 302 but got " + status); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicPunishmentApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicPunishmentApiTest.java index d11c1f3..3328c32 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicPunishmentApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicPunishmentApiTest.java @@ -1,54 +1,54 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicPunishmentApiTest { - - static ApiClient api; - - private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getAppealInfo() throws Exception { - // Create a punishment to query appeal info for - var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( - "targetUuid", TEST_UUID, - "issuerName", "TestBot", - "type_ordinal", 14, - "reason", "Public appeal info test", - "duration", 300, - "severity", "LOW", - "status", "ACTIVE" - )); - if (createResponse.statusCode() != 200) { - return; - } - String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); - - var response = api.publicGet("/v1/public/punishment/" + punishmentId + "/appeal-info"); - // Punishment exists but hasn't been executed on a server yet, so appeal info may return 400 - int status = response.statusCode(); - assertTrue(status == 200 || status == 400, "Expected 200 or 400 but got " + status); - - // Cleanup - api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( - "issuerName", "TestBot", - "reason", "cleanup" - )); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicPunishmentApiTest { + + static ApiClient api; + + private static final String TEST_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getAppealInfo() throws Exception { + // Create a punishment to query appeal info for + var createResponse = api.minecraftPost("/v1/minecraft/punishments/dynamic", Map.of( + "targetUuid", TEST_UUID, + "issuerName", "TestBot", + "type_ordinal", 14, + "reason", "Public appeal info test", + "duration", 300, + "severity", "LOW", + "status", "ACTIVE" + )); + if (createResponse.statusCode() != 200) { + return; + } + String punishmentId = JsonHelper.parseObject(createResponse.body()).get("punishmentId").getAsString(); + + var response = api.publicGet("/v1/public/punishment/" + punishmentId + "/appeal-info"); + // Punishment exists but hasn't been executed on a server yet, so appeal info may return 400 + int status = response.statusCode(); + assertTrue(status == 200 || status == 400, "Expected 200 or 400 but got " + status); + + // Cleanup + api.minecraftPost("/v1/minecraft/punishments/" + punishmentId + "/pardon", Map.of( + "issuerName", "TestBot", + "reason", "cleanup" + )); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicServerApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicServerApiTest.java index 470f0d3..553531a 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicServerApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicServerApiTest.java @@ -1,20 +1,20 @@ -package gg.modl.backend.public_api; - -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -class PublicServerApiTest { - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - } - - @Disabled("Skipped: would create a new server registration on staging") - @Test - void registerServer() throws Exception {} -} - +package gg.modl.backend.public_api; + +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +class PublicServerApiTest { + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + } + + @Disabled("Skipped: would create a new server registration on staging") + @Test + void registerServer() throws Exception {} +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicSettingsApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicSettingsApiTest.java index ec28565..08e1122 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicSettingsApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicSettingsApiTest.java @@ -1,30 +1,30 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicSettingsApiTest { - - static ApiClient api; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - } - - @Test - void getPublicSettings() throws Exception { - var response = api.publicGet("/v1/public/settings"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("serverExists")); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicSettingsApiTest { + + static ApiClient api; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + } + + @Test + void getPublicSettings() throws Exception { + var response = api.publicGet("/v1/public/settings"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("serverExists")); + } +} + diff --git a/src/test/java/gg/modl/backend/public_api/PublicTicketApiTest.java b/src/test/java/gg/modl/backend/public_api/PublicTicketApiTest.java index 7e5c78e..ead9243 100644 --- a/src/test/java/gg/modl/backend/public_api/PublicTicketApiTest.java +++ b/src/test/java/gg/modl/backend/public_api/PublicTicketApiTest.java @@ -1,215 +1,215 @@ -package gg.modl.backend.public_api; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import gg.modl.backend.support.ApiClient; -import gg.modl.backend.support.JsonHelper; -import gg.modl.backend.support.StagingCredentials; -import gg.modl.backend.support.TestDataProvider; -import gg.modl.backend.support.TestDatabase; -import java.util.Map; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Test; - -class PublicTicketApiTest { - - static ApiClient api; - - private static String testUuid; - - @BeforeAll - static void setUp() { - Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); - api = new ApiClient(); - - testUuid = TestDataProvider.getPlayers().get(0).uuid(); - } - - @Test - void createAndGetTicket() throws Exception { - var createResponse = api.publicPost("/v1/public/tickets", Map.of( - "type", "bug_report", - "subject", "Public API Test Ticket", - "description", "Created by automated public API test", - "creatorName", "PublicUser", - "creatorUuid", testUuid - )); - int status = createResponse.statusCode(); - assertTrue(status == 200 || status == 201 || status == 429, "Expected 200, 201, or 429 but got " + status); - if (status == 429) { - return; - } - - var json = JsonHelper.parseObject(createResponse.body()); - assertTrue(json.has("ticketId")); - String ticketId = json.get("ticketId").getAsString(); - - // DB VERIFICATION: confirm ticket created - if (TestDatabase.isAvailable()) { - var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); - assertNotNull(dbTicket, "Ticket should exist in DB after public creation"); - assertEquals("bug", dbTicket.getString("type")); - } - - // Get ticket - var getResponse = api.publicGet("/v1/public/tickets/" + ticketId); - JsonHelper.assertStatus(getResponse, 200); - - // Cleanup via panel - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void createUnfinishedTicket() throws Exception { - var response = api.publicPost("/v1/public/tickets/unfinished", Map.of( - "type", "bug_report", - "creatorName", "PublicUser", - "creatorUuid", testUuid - )); - int status = response.statusCode(); - assertTrue(status == 200 || status == 201 || status == 429, "Expected 200, 201, or 429 but got " + status); - - if (status == 200 || status == 201) { - var json = JsonHelper.parseObject(response.body()); - if (json.has("ticketId")) { - api.panelPatch("/v1/panel/tickets/" + json.get("ticketId").getAsString(), - Map.of("status", "closed")); - } - } - } - - @Test - void getTicketStatus() throws Exception { - // Create a ticket first - var createResponse = api.publicPost("/v1/public/tickets", Map.of( - "type", "bug_report", - "subject", "Public API Test - status", - "creatorUuid", testUuid - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var response = api.publicGet("/v1/public/tickets/" + ticketId + "/status"); - JsonHelper.assertStatus(response, 200); - var json = JsonHelper.parseObject(response.body()); - assertTrue(json.has("status")); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void addReplyToTicket() throws Exception { - var createResponse = api.publicPost("/v1/public/tickets", Map.of( - "type", "bug_report", - "subject", "Public API Test - reply", - "creatorUuid", testUuid - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var response = api.publicPost("/v1/public/tickets/" + ticketId + "/replies", Map.of( - "name", "PublicUser", - "content", "Public test reply" - )); - int status = response.statusCode(); - assertTrue(status == 200 || status == 201 || status == 429, "Expected 200, 201, or 429 but got " + status); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void submitTicketForm() throws Exception { - var createResponse = api.publicPost("/v1/public/tickets/unfinished", Map.of( - "type", "bug_report", - "creatorUuid", testUuid - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var response = api.publicPost("/v1/public/tickets/" + ticketId + "/submit", Map.of( - "subject", "Submitted form test", - "description", "Test submission" - )); - int submitStatus = response.statusCode(); - assertTrue(submitStatus == 200 || submitStatus == 429, "Expected 200 or 429 but got " + submitStatus); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void requestVerification() throws Exception { - var createResponse = api.publicPost("/v1/public/tickets", Map.of( - "type", "bug_report", - "subject", "Public API Test - verify", - "creatorUuid", testUuid, - "emailAuthEnabled", true, - "creatorEmail", "test@example.com" - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var response = api.publicPost("/v1/public/tickets/" + ticketId + "/request-verification", Map.of()); - int verifyStatus = response.statusCode(); - assertTrue(verifyStatus == 200 || verifyStatus == 429, "Expected 200 or 429 but got " + verifyStatus); - - // Cleanup - api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); - } - - @Test - void verifyCode() throws Exception { - var response = api.publicPost("/v1/public/tickets/nonexistent-id/verify", Map.of( - "code", "000000" - )); - assertEquals(403, response.statusCode()); - } - - @Test - void createTicketRejectsInvalidEmail() throws Exception { - var response = api.publicPost("/v1/public/tickets", Map.of( - "type", "bug_report", - "subject", "Public API Test - invalid email", - "creatorName", "PublicUser", - "creatorEmail", "asfas" - )); - - int status = response.statusCode(); - assertTrue(status == 400 || status == 429, "Expected 400 or 429 but got " + status); - } - - @Test - void submitTicketFormRejectsInvalidEmail() throws Exception { - var createResponse = api.publicPost("/v1/public/tickets/unfinished", Map.of( - "type", "bug_report", - "creatorUuid", testUuid - )); - if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { - return; - } - String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); - - var response = api.publicPost("/v1/public/tickets/" + ticketId + "/submit", Map.of( - "subject", "Invalid email submit test", - "creatorEmail", "asfas", - "formData", Map.of("description", "Test submission") - )); - - int status = response.statusCode(); - assertTrue(status == 400 || status == 429, "Expected 400 or 429 but got " + status); - } -} - +package gg.modl.backend.public_api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import gg.modl.backend.support.ApiClient; +import gg.modl.backend.support.JsonHelper; +import gg.modl.backend.support.StagingCredentials; +import gg.modl.backend.support.TestDataProvider; +import gg.modl.backend.support.TestDatabase; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class PublicTicketApiTest { + + static ApiClient api; + + private static String testUuid; + + @BeforeAll + static void setUp() { + Assumptions.assumeTrue(StagingCredentials.isPublicApiAvailable(), StagingCredentials.publicApiUnavailableReason()); + api = new ApiClient(); + + testUuid = TestDataProvider.getPlayers().get(0).uuid(); + } + + @Test + void createAndGetTicket() throws Exception { + var createResponse = api.publicPost("/v1/public/tickets", Map.of( + "type", "bug_report", + "subject", "Public API Test Ticket", + "description", "Created by automated public API test", + "creatorName", "PublicUser", + "creatorUuid", testUuid + )); + int status = createResponse.statusCode(); + assertTrue(status == 200 || status == 201 || status == 429, "Expected 200, 201, or 429 but got " + status); + if (status == 429) { + return; + } + + var json = JsonHelper.parseObject(createResponse.body()); + assertTrue(json.has("ticketId")); + String ticketId = json.get("ticketId").getAsString(); + + // DB VERIFICATION: confirm ticket created + if (TestDatabase.isAvailable()) { + var dbTicket = TestDatabase.getInstance().findTicketById(ticketId); + assertNotNull(dbTicket, "Ticket should exist in DB after public creation"); + assertEquals("bug", dbTicket.getString("type")); + } + + // Get ticket + var getResponse = api.publicGet("/v1/public/tickets/" + ticketId); + JsonHelper.assertStatus(getResponse, 200); + + // Cleanup via panel + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void createUnfinishedTicket() throws Exception { + var response = api.publicPost("/v1/public/tickets/unfinished", Map.of( + "type", "bug_report", + "creatorName", "PublicUser", + "creatorUuid", testUuid + )); + int status = response.statusCode(); + assertTrue(status == 200 || status == 201 || status == 429, "Expected 200, 201, or 429 but got " + status); + + if (status == 200 || status == 201) { + var json = JsonHelper.parseObject(response.body()); + if (json.has("ticketId")) { + api.panelPatch("/v1/panel/tickets/" + json.get("ticketId").getAsString(), + Map.of("status", "closed")); + } + } + } + + @Test + void getTicketStatus() throws Exception { + // Create a ticket first + var createResponse = api.publicPost("/v1/public/tickets", Map.of( + "type", "bug_report", + "subject", "Public API Test - status", + "creatorUuid", testUuid + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var response = api.publicGet("/v1/public/tickets/" + ticketId + "/status"); + JsonHelper.assertStatus(response, 200); + var json = JsonHelper.parseObject(response.body()); + assertTrue(json.has("status")); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void addReplyToTicket() throws Exception { + var createResponse = api.publicPost("/v1/public/tickets", Map.of( + "type", "bug_report", + "subject", "Public API Test - reply", + "creatorUuid", testUuid + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var response = api.publicPost("/v1/public/tickets/" + ticketId + "/replies", Map.of( + "name", "PublicUser", + "content", "Public test reply" + )); + int status = response.statusCode(); + assertTrue(status == 200 || status == 201 || status == 429, "Expected 200, 201, or 429 but got " + status); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void submitTicketForm() throws Exception { + var createResponse = api.publicPost("/v1/public/tickets/unfinished", Map.of( + "type", "bug_report", + "creatorUuid", testUuid + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var response = api.publicPost("/v1/public/tickets/" + ticketId + "/submit", Map.of( + "subject", "Submitted form test", + "description", "Test submission" + )); + int submitStatus = response.statusCode(); + assertTrue(submitStatus == 200 || submitStatus == 429, "Expected 200 or 429 but got " + submitStatus); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void requestVerification() throws Exception { + var createResponse = api.publicPost("/v1/public/tickets", Map.of( + "type", "bug_report", + "subject", "Public API Test - verify", + "creatorUuid", testUuid, + "emailAuthEnabled", true, + "creatorEmail", "test@example.com" + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var response = api.publicPost("/v1/public/tickets/" + ticketId + "/request-verification", Map.of()); + int verifyStatus = response.statusCode(); + assertTrue(verifyStatus == 200 || verifyStatus == 429, "Expected 200 or 429 but got " + verifyStatus); + + // Cleanup + api.panelPatch("/v1/panel/tickets/" + ticketId, Map.of("status", "closed")); + } + + @Test + void verifyCode() throws Exception { + var response = api.publicPost("/v1/public/tickets/nonexistent-id/verify", Map.of( + "code", "000000" + )); + assertEquals(403, response.statusCode()); + } + + @Test + void createTicketRejectsInvalidEmail() throws Exception { + var response = api.publicPost("/v1/public/tickets", Map.of( + "type", "bug_report", + "subject", "Public API Test - invalid email", + "creatorName", "PublicUser", + "creatorEmail", "asfas" + )); + + int status = response.statusCode(); + assertTrue(status == 400 || status == 429, "Expected 400 or 429 but got " + status); + } + + @Test + void submitTicketFormRejectsInvalidEmail() throws Exception { + var createResponse = api.publicPost("/v1/public/tickets/unfinished", Map.of( + "type", "bug_report", + "creatorUuid", testUuid + )); + if (createResponse.statusCode() != 200 && createResponse.statusCode() != 201) { + return; + } + String ticketId = JsonHelper.parseObject(createResponse.body()).get("ticketId").getAsString(); + + var response = api.publicPost("/v1/public/tickets/" + ticketId + "/submit", Map.of( + "subject", "Invalid email submit test", + "creatorEmail", "asfas", + "formData", Map.of("description", "Test submission") + )); + + int status = response.statusCode(); + assertTrue(status == 400 || status == 429, "Expected 400 or 429 but got " + status); + } +} + diff --git a/src/test/java/gg/modl/backend/punishment/controller/PublicPunishmentControllerTest.java b/src/test/java/gg/modl/backend/punishment/controller/PublicPunishmentControllerTest.java index b12c25b..201ef42 100644 --- a/src/test/java/gg/modl/backend/punishment/controller/PublicPunishmentControllerTest.java +++ b/src/test/java/gg/modl/backend/punishment/controller/PublicPunishmentControllerTest.java @@ -1,61 +1,61 @@ -package gg.modl.backend.punishment.controller; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - -import com.google.protobuf.Struct; -import gg.modl.backend.player.service.PunishmentQueryService; -import gg.modl.backend.infrastructure.rest.RequestAttribute; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.proto.modl.v1.PublicPunishmentAppealInfoResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; - -@ExtendWith(MockitoExtension.class) -class PublicPunishmentControllerTest { - - @Mock - private PunishmentQueryService punishmentQueryService; - - @Mock - private HttpServletRequest request; - - private PublicPunishmentController controller; - - @BeforeEach - void setUp() { - controller = new PublicPunishmentController(punishmentQueryService); - } - - @Test - void getAppealInfoUsesWorkflowStatusForExistingAppeals() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - Map serviceResult = new java.util.HashMap<>(); - serviceResult.put("id", "punishment-1"); - Map existingAppealData = new java.util.HashMap<>(); - existingAppealData.put("status", "rejected"); - existingAppealData.put("appealWorkflowStatus", "rejected"); - serviceResult.put("existingAppeal", existingAppealData); - - when(request.getAttribute(RequestAttribute.SERVER)).thenReturn(server); - when(punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, "punishment-1")) - .thenReturn(Optional.of(serviceResult)); - - ResponseEntity response = controller.getAppealInfo("punishment-1", request); - - assertEquals(200, response.getStatusCode().value()); - PublicPunishmentAppealInfoResponse body = (PublicPunishmentAppealInfoResponse) response.getBody(); - Struct existingAppeal = body.getExistingAppeal(); - assertEquals("rejected", existingAppeal.getFieldsOrThrow("status").getStringValue()); - assertEquals("rejected", existingAppeal.getFieldsOrThrow("appealWorkflowStatus").getStringValue()); - } -} +package gg.modl.backend.punishment.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +import com.google.protobuf.Struct; +import gg.modl.backend.player.service.PunishmentQueryService; +import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.proto.modl.v1.PublicPunishmentAppealInfoResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; + +@ExtendWith(MockitoExtension.class) +class PublicPunishmentControllerTest { + + @Mock + private PunishmentQueryService punishmentQueryService; + + @Mock + private HttpServletRequest request; + + private PublicPunishmentController controller; + + @BeforeEach + void setUp() { + controller = new PublicPunishmentController(punishmentQueryService); + } + + @Test + void getAppealInfoUsesWorkflowStatusForExistingAppeals() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + Map serviceResult = new java.util.HashMap<>(); + serviceResult.put("id", "punishment-1"); + Map existingAppealData = new java.util.HashMap<>(); + existingAppealData.put("status", "rejected"); + existingAppealData.put("appealWorkflowStatus", "rejected"); + serviceResult.put("existingAppeal", existingAppealData); + + when(request.getAttribute(RequestAttribute.SERVER)).thenReturn(server); + when(punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, "punishment-1")) + .thenReturn(Optional.of(serviceResult)); + + ResponseEntity response = controller.getAppealInfo("punishment-1", request); + + assertEquals(200, response.getStatusCode().value()); + PublicPunishmentAppealInfoResponse body = (PublicPunishmentAppealInfoResponse) response.getBody(); + Struct existingAppeal = body.getExistingAppeal(); + assertEquals("rejected", existingAppeal.getFieldsOrThrow("status").getStringValue()); + assertEquals("rejected", existingAppeal.getFieldsOrThrow("appealWorkflowStatus").getStringValue()); + } +} diff --git a/src/test/java/gg/modl/backend/storage/service/EvidenceUploadServiceTest.java b/src/test/java/gg/modl/backend/storage/service/EvidenceUploadServiceTest.java index 3c80cfb..420806e 100644 --- a/src/test/java/gg/modl/backend/storage/service/EvidenceUploadServiceTest.java +++ b/src/test/java/gg/modl/backend/storage/service/EvidenceUploadServiceTest.java @@ -1,197 +1,197 @@ -package gg.modl.backend.storage.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.player.service.PunishmentEvidenceService; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; -import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; -import gg.modl.backend.server.ServerService; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.storage.data.StorageFileDocument; -import gg.modl.backend.storage.dto.request.EvidenceConfirmUploadRequest; -import gg.modl.backend.storage.dto.request.EvidenceItemRequest; -import gg.modl.backend.storage.dto.request.EvidencePresignUploadRequest; -import gg.modl.backend.storage.dto.request.SubmitEvidenceRequest; -import gg.modl.backend.storage.dto.response.UploadResponse; -import java.time.Instant; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class EvidenceUploadServiceTest { - - @Mock - private EvidenceUploadTokenService tokenService; - - @Mock - private S3StorageService s3StorageService; - - @Mock - private PlayerMongoRepository playerRepository; - - @Mock - private ServerService serverService; - - @Mock - private MediaValidationService validationService; - - @Mock - private PunishmentEvidenceService punishmentEvidenceService; - - @Mock - private StorageMetadataService storageMetadataService; - - @Mock - private UploadOrchestrationService uploadOrchestrationService; - - private EvidenceUploadService evidenceUploadService; - - @BeforeEach - void setUp() { - evidenceUploadService = new EvidenceUploadService( - tokenService, - s3StorageService, - playerRepository, - serverService, - validationService, - punishmentEvidenceService, - storageMetadataService, - uploadOrchestrationService - ); - } - - private EvidenceUploadTokenService.UploadToken uploadToken() { - return new EvidenceUploadTokenService.UploadToken( - "token-1", - "db", - "PUN-1", - "player-1", - "Moderator", - Instant.now() - ); - } - - @Test - void submitEvidenceDelegatesPunishmentMutationAndInvalidatesToken() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); - when(s3StorageService.getCdnDomain()).thenReturn("cdn.example.com"); - when(serverService.getServerByDatabaseName("db")).thenReturn(server); - when(storageMetadataService.findConfirmedFiles(eq(server), any())) - .thenReturn(Map.of("db/evidence/PUN-1/file.png", new StorageFileDocument("db/evidence/PUN-1/file.png", "file.png", 42L, "image/png", "evidence"))); - when(punishmentEvidenceService.addUploadedEvidence(eq(server), eq("PUN-1"), eq("Moderator"), any(), any())) - .thenReturn(new PunishmentOperationResult( - PunishmentOperationStatus.SUCCESS, - "ok", - true, - 1 - )); - - EvidenceUploadService.SubmitEvidenceResult result = evidenceUploadService.submitEvidence( - "token-1", - new SubmitEvidenceRequest(List.of( - new EvidenceItemRequest( - "https://cdn.example.com/db/evidence/PUN-1/file.png", - "file.png", - "image/png", - 42L - ) - )) - ); - - assertEquals(EvidenceUploadService.SubmitEvidenceStatus.SUCCESS, result.status()); - verify(punishmentEvidenceService).addUploadedEvidence(eq(server), eq("PUN-1"), eq("Moderator"), any(), any()); - verify(tokenService).invalidateToken("token-1"); - } - - @Test - void confirmUploadMapsQuotaExceededOutcomeFromOrchestration() { - String key = "db/evidence/PUN-1/file.png"; - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); - when(validationService.isKeyOwnedByServer(key, "db")).thenReturn(true); - when(serverService.getServerByDatabaseName("db")).thenReturn(server); - when(uploadOrchestrationService.confirm(server, key, false)) - .thenReturn(new UploadOrchestrationService.ConfirmOutcome( - UploadOrchestrationService.ConfirmStatus.QUOTA_EXCEEDED, null)); - - EvidenceUploadService.ConfirmUploadResult result = evidenceUploadService.confirmUpload( - "token-1", - new EvidenceConfirmUploadRequest(key) - ); - - assertEquals(EvidenceUploadService.ConfirmUploadStatus.QUOTA_EXCEEDED, result.status()); - } - - @Test - void confirmUploadReturnsSuccessWithDetailsFromOrchestration() { - String key = "db/evidence/PUN-1/file.png"; - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - UploadResponse details = new UploadResponse(key, "https://cdn.example.com/" + key, "file.png", 42L, "image/png"); - - when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); - when(validationService.isKeyOwnedByServer(key, "db")).thenReturn(true); - when(serverService.getServerByDatabaseName("db")).thenReturn(server); - when(uploadOrchestrationService.confirm(server, key, false)) - .thenReturn(new UploadOrchestrationService.ConfirmOutcome( - UploadOrchestrationService.ConfirmStatus.SUCCESS, details)); - - EvidenceUploadService.ConfirmUploadResult result = evidenceUploadService.confirmUpload( - "token-1", - new EvidenceConfirmUploadRequest(key) - ); - - assertEquals(EvidenceUploadService.ConfirmUploadStatus.SUCCESS, result.status()); - assertEquals(details, result.upload()); - } - - @Test - void confirmUploadRejectsKeyOutsidePunishmentScopeWithoutOrchestrating() { - String key = "db/evidence/OTHER-PUN/file.png"; - - when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); - when(validationService.isKeyOwnedByServer(key, "db")).thenReturn(true); - - EvidenceUploadService.ConfirmUploadResult result = evidenceUploadService.confirmUpload( - "token-1", - new EvidenceConfirmUploadRequest(key) - ); - - assertEquals(EvidenceUploadService.ConfirmUploadStatus.INVALID_KEY, result.status()); - verify(uploadOrchestrationService, never()).confirm(any(), any(), anyBoolean()); - } - - @Test - void presignUploadMapsQuotaExceededOutcomeFromOrchestration() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - - when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); - when(s3StorageService.isConfigured()).thenReturn(true); - when(serverService.getServerByDatabaseName("db")).thenReturn(server); - when(uploadOrchestrationService.presign(eq(server), any())) - .thenReturn(new UploadOrchestrationService.PresignOutcome( - UploadOrchestrationService.PresignStatus.QUOTA_EXCEEDED, "Storage quota exceeded", null)); - - EvidenceUploadService.PresignUploadResult result = evidenceUploadService.presignUpload( - "token-1", - new EvidencePresignUploadRequest("file.png", "image/png", 42L) - ); - - assertEquals(EvidenceUploadService.PresignUploadStatus.QUOTA_EXCEEDED, result.status()); - } -} +package gg.modl.backend.storage.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; +import gg.modl.backend.player.service.PunishmentEvidenceService; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationResult; +import gg.modl.backend.player.service.PunishmentQueryService.PunishmentOperationStatus; +import gg.modl.backend.server.ServerService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.storage.data.StorageFileDocument; +import gg.modl.backend.storage.dto.request.EvidenceConfirmUploadRequest; +import gg.modl.backend.storage.dto.request.EvidenceItemRequest; +import gg.modl.backend.storage.dto.request.EvidencePresignUploadRequest; +import gg.modl.backend.storage.dto.request.SubmitEvidenceRequest; +import gg.modl.backend.storage.dto.response.UploadResponse; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class EvidenceUploadServiceTest { + + @Mock + private EvidenceUploadTokenService tokenService; + + @Mock + private S3StorageService s3StorageService; + + @Mock + private PlayerMongoRepository playerRepository; + + @Mock + private ServerService serverService; + + @Mock + private MediaValidationService validationService; + + @Mock + private PunishmentEvidenceService punishmentEvidenceService; + + @Mock + private StorageMetadataService storageMetadataService; + + @Mock + private UploadOrchestrationService uploadOrchestrationService; + + private EvidenceUploadService evidenceUploadService; + + @BeforeEach + void setUp() { + evidenceUploadService = new EvidenceUploadService( + tokenService, + s3StorageService, + playerRepository, + serverService, + validationService, + punishmentEvidenceService, + storageMetadataService, + uploadOrchestrationService + ); + } + + private EvidenceUploadTokenService.UploadToken uploadToken() { + return new EvidenceUploadTokenService.UploadToken( + "token-1", + "db", + "PUN-1", + "player-1", + "Moderator", + Instant.now() + ); + } + + @Test + void submitEvidenceDelegatesPunishmentMutationAndInvalidatesToken() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); + when(s3StorageService.getCdnDomain()).thenReturn("cdn.example.com"); + when(serverService.getServerByDatabaseName("db")).thenReturn(server); + when(storageMetadataService.findConfirmedFiles(eq(server), any())) + .thenReturn(Map.of("db/evidence/PUN-1/file.png", new StorageFileDocument("db/evidence/PUN-1/file.png", "file.png", 42L, "image/png", "evidence"))); + when(punishmentEvidenceService.addUploadedEvidence(eq(server), eq("PUN-1"), eq("Moderator"), any(), any())) + .thenReturn(new PunishmentOperationResult( + PunishmentOperationStatus.SUCCESS, + "ok", + true, + 1 + )); + + EvidenceUploadService.SubmitEvidenceResult result = evidenceUploadService.submitEvidence( + "token-1", + new SubmitEvidenceRequest(List.of( + new EvidenceItemRequest( + "https://cdn.example.com/db/evidence/PUN-1/file.png", + "file.png", + "image/png", + 42L + ) + )) + ); + + assertEquals(EvidenceUploadService.SubmitEvidenceStatus.SUCCESS, result.status()); + verify(punishmentEvidenceService).addUploadedEvidence(eq(server), eq("PUN-1"), eq("Moderator"), any(), any()); + verify(tokenService).invalidateToken("token-1"); + } + + @Test + void confirmUploadMapsQuotaExceededOutcomeFromOrchestration() { + String key = "db/evidence/PUN-1/file.png"; + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); + when(validationService.isKeyOwnedByServer(key, "db")).thenReturn(true); + when(serverService.getServerByDatabaseName("db")).thenReturn(server); + when(uploadOrchestrationService.confirm(server, key, false)) + .thenReturn(new UploadOrchestrationService.ConfirmOutcome( + UploadOrchestrationService.ConfirmStatus.QUOTA_EXCEEDED, null)); + + EvidenceUploadService.ConfirmUploadResult result = evidenceUploadService.confirmUpload( + "token-1", + new EvidenceConfirmUploadRequest(key) + ); + + assertEquals(EvidenceUploadService.ConfirmUploadStatus.QUOTA_EXCEEDED, result.status()); + } + + @Test + void confirmUploadReturnsSuccessWithDetailsFromOrchestration() { + String key = "db/evidence/PUN-1/file.png"; + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + UploadResponse details = new UploadResponse(key, "https://cdn.example.com/" + key, "file.png", 42L, "image/png"); + + when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); + when(validationService.isKeyOwnedByServer(key, "db")).thenReturn(true); + when(serverService.getServerByDatabaseName("db")).thenReturn(server); + when(uploadOrchestrationService.confirm(server, key, false)) + .thenReturn(new UploadOrchestrationService.ConfirmOutcome( + UploadOrchestrationService.ConfirmStatus.SUCCESS, details)); + + EvidenceUploadService.ConfirmUploadResult result = evidenceUploadService.confirmUpload( + "token-1", + new EvidenceConfirmUploadRequest(key) + ); + + assertEquals(EvidenceUploadService.ConfirmUploadStatus.SUCCESS, result.status()); + assertEquals(details, result.upload()); + } + + @Test + void confirmUploadRejectsKeyOutsidePunishmentScopeWithoutOrchestrating() { + String key = "db/evidence/OTHER-PUN/file.png"; + + when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); + when(validationService.isKeyOwnedByServer(key, "db")).thenReturn(true); + + EvidenceUploadService.ConfirmUploadResult result = evidenceUploadService.confirmUpload( + "token-1", + new EvidenceConfirmUploadRequest(key) + ); + + assertEquals(EvidenceUploadService.ConfirmUploadStatus.INVALID_KEY, result.status()); + verify(uploadOrchestrationService, never()).confirm(any(), any(), anyBoolean()); + } + + @Test + void presignUploadMapsQuotaExceededOutcomeFromOrchestration() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + + when(tokenService.validateToken("token-1")).thenReturn(uploadToken()); + when(s3StorageService.isConfigured()).thenReturn(true); + when(serverService.getServerByDatabaseName("db")).thenReturn(server); + when(uploadOrchestrationService.presign(eq(server), any())) + .thenReturn(new UploadOrchestrationService.PresignOutcome( + UploadOrchestrationService.PresignStatus.QUOTA_EXCEEDED, "Storage quota exceeded", null)); + + EvidenceUploadService.PresignUploadResult result = evidenceUploadService.presignUpload( + "token-1", + new EvidencePresignUploadRequest("file.png", "image/png", 42L) + ); + + assertEquals(EvidenceUploadService.PresignUploadStatus.QUOTA_EXCEEDED, result.status()); + } +} diff --git a/src/test/java/gg/modl/backend/support/ApiClient.java b/src/test/java/gg/modl/backend/support/ApiClient.java index caeae2f..23b7e70 100644 --- a/src/test/java/gg/modl/backend/support/ApiClient.java +++ b/src/test/java/gg/modl/backend/support/ApiClient.java @@ -1,170 +1,170 @@ -package gg.modl.backend.support; - -import com.google.gson.Gson; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; - -public final class ApiClient { - - private final HttpClient httpClient; - private final String baseUrl; - private final String apiKey; - private final String sessionToken; - private final String serverDomain; - private final String panelOrigin; - private final Gson gson = new Gson(); - - public ApiClient() { - this.baseUrl = StagingCredentials.baseUrl(); - this.apiKey = StagingCredentials.apiKey(); - this.sessionToken = StagingCredentials.sessionToken(); - this.serverDomain = StagingCredentials.serverDomain(); - this.panelOrigin = resolveOrigin( - StagingCredentials.panelOrigin() != null - ? StagingCredentials.panelOrigin() - : "https://admin.modl.gg" - ); - this.httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(15)) - .followRedirects(HttpClient.Redirect.NORMAL) - .build(); - } - - // ── Minecraft auth: X-API-Key + X-Server-Domain ── - - private static String resolveOrigin(String rawBaseUrl) { - URI uri = URI.create(rawBaseUrl); - if (uri.getScheme() == null || uri.getHost() == null) { - throw new IllegalArgumentException("Invalid base URL for origin derivation: " + rawBaseUrl); - } - StringBuilder origin = new StringBuilder() - .append(uri.getScheme()) - .append("://") - .append(uri.getHost()); - if (uri.getPort() != -1) { - origin.append(":").append(uri.getPort()); - } - return origin.toString(); - } - - public HttpResponse minecraftGet(String path) throws Exception { - return send(serverBuilder(path) - .header("X-API-Key", apiKey) - .GET() - .build()); - } - - private HttpRequest.Builder serverBuilder(String path) { - return newBuilder(path) - .header("X-Server-Domain", serverDomain) - .header("X-Forwarded-Host", serverDomain); - } - - // ── Panel auth: X-Server-Domain + Cookie: MODL_SESSION= ── - - private HttpRequest.Builder newBuilder(String path) { - return HttpRequest.newBuilder() - .uri(URI.create(baseUrl + path)) - .timeout(Duration.ofSeconds(30)) - .header("Accept", "application/json") - .header("User-Agent", "modl-backend-test-suite"); - } - - private HttpResponse send(HttpRequest request) throws Exception { - for (int attempt = 0; attempt < 5; attempt++) { - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - if (response.statusCode() != 429) { - return response; - } - // Rate limited — wait and retry - Thread.sleep(1000L * (attempt + 1)); - } - return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - } - - public HttpResponse minecraftPost(String path, Object body) throws Exception { - return send(serverBuilder(path) - .header("X-API-Key", apiKey) - .header("Content-Type", "application/json") - .POST(jsonBody(body)) - .build()); - } - - private HttpRequest.BodyPublisher jsonBody(Object body) { - if (body == null) { - return HttpRequest.BodyPublishers.noBody(); - } - String json = (body instanceof String s) ? s : gson.toJson(body); - return HttpRequest.BodyPublishers.ofString(json); - } - - public HttpResponse minecraftPatch(String path, Object body) throws Exception { - return send(serverBuilder(path) - .header("X-API-Key", apiKey) - .header("Content-Type", "application/json") - .method("PATCH", jsonBody(body)) - .build()); - } - - // ── Public: X-Server-Domain only ── - - public HttpResponse panelGet(String path) throws Exception { - return send(panelBuilder(path).GET().build()); - } - - private HttpRequest.Builder panelBuilder(String path) { - return serverBuilder(path) - .header("Origin", panelOrigin) - .header("Referer", panelOrigin + "/panel") - .header("Cookie", "MODL_SESSION=" + sessionToken); - } - - // ── Raw (no auth) ── - - public HttpResponse panelPost(String path, Object body) throws Exception { - return send(panelBuilder(path) - .header("Content-Type", "application/json") - .POST(jsonBody(body)) - .build()); - } - - // ── Helpers ── - - public HttpResponse panelPut(String path, Object body) throws Exception { - return send(panelBuilder(path) - .header("Content-Type", "application/json") - .PUT(jsonBody(body)) - .build()); - } - - public HttpResponse panelPatch(String path, Object body) throws Exception { - return send(panelBuilder(path) - .header("Content-Type", "application/json") - .method("PATCH", jsonBody(body)) - .build()); - } - - public HttpResponse panelDelete(String path) throws Exception { - return send(panelBuilder(path).DELETE().build()); - } - - public HttpResponse publicGet(String path) throws Exception { - return send(serverBuilder(path) - .GET() - .build()); - } - - public HttpResponse publicPost(String path, Object body) throws Exception { - return send(serverBuilder(path) - .header("Content-Type", "application/json") - .POST(jsonBody(body)) - .build()); - } - - public HttpResponse rawGet(String path) throws Exception { - return send(newBuilder(path).GET().build()); - } -} +package gg.modl.backend.support; + +import com.google.gson.Gson; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +public final class ApiClient { + + private final HttpClient httpClient; + private final String baseUrl; + private final String apiKey; + private final String sessionToken; + private final String serverDomain; + private final String panelOrigin; + private final Gson gson = new Gson(); + + public ApiClient() { + this.baseUrl = StagingCredentials.baseUrl(); + this.apiKey = StagingCredentials.apiKey(); + this.sessionToken = StagingCredentials.sessionToken(); + this.serverDomain = StagingCredentials.serverDomain(); + this.panelOrigin = resolveOrigin( + StagingCredentials.panelOrigin() != null + ? StagingCredentials.panelOrigin() + : "https://admin.modl.gg" + ); + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + // ── Minecraft auth: X-API-Key + X-Server-Domain ── + + private static String resolveOrigin(String rawBaseUrl) { + URI uri = URI.create(rawBaseUrl); + if (uri.getScheme() == null || uri.getHost() == null) { + throw new IllegalArgumentException("Invalid base URL for origin derivation: " + rawBaseUrl); + } + StringBuilder origin = new StringBuilder() + .append(uri.getScheme()) + .append("://") + .append(uri.getHost()); + if (uri.getPort() != -1) { + origin.append(":").append(uri.getPort()); + } + return origin.toString(); + } + + public HttpResponse minecraftGet(String path) throws Exception { + return send(serverBuilder(path) + .header("X-API-Key", apiKey) + .GET() + .build()); + } + + private HttpRequest.Builder serverBuilder(String path) { + return newBuilder(path) + .header("X-Server-Domain", serverDomain) + .header("X-Forwarded-Host", serverDomain); + } + + // ── Panel auth: X-Server-Domain + Cookie: MODL_SESSION= ── + + private HttpRequest.Builder newBuilder(String path) { + return HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .timeout(Duration.ofSeconds(30)) + .header("Accept", "application/json") + .header("User-Agent", "modl-backend-test-suite"); + } + + private HttpResponse send(HttpRequest request) throws Exception { + for (int attempt = 0; attempt < 5; attempt++) { + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 429) { + return response; + } + // Rate limited — wait and retry + Thread.sleep(1000L * (attempt + 1)); + } + return httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + } + + public HttpResponse minecraftPost(String path, Object body) throws Exception { + return send(serverBuilder(path) + .header("X-API-Key", apiKey) + .header("Content-Type", "application/json") + .POST(jsonBody(body)) + .build()); + } + + private HttpRequest.BodyPublisher jsonBody(Object body) { + if (body == null) { + return HttpRequest.BodyPublishers.noBody(); + } + String json = (body instanceof String s) ? s : gson.toJson(body); + return HttpRequest.BodyPublishers.ofString(json); + } + + public HttpResponse minecraftPatch(String path, Object body) throws Exception { + return send(serverBuilder(path) + .header("X-API-Key", apiKey) + .header("Content-Type", "application/json") + .method("PATCH", jsonBody(body)) + .build()); + } + + // ── Public: X-Server-Domain only ── + + public HttpResponse panelGet(String path) throws Exception { + return send(panelBuilder(path).GET().build()); + } + + private HttpRequest.Builder panelBuilder(String path) { + return serverBuilder(path) + .header("Origin", panelOrigin) + .header("Referer", panelOrigin + "/panel") + .header("Cookie", "MODL_SESSION=" + sessionToken); + } + + // ── Raw (no auth) ── + + public HttpResponse panelPost(String path, Object body) throws Exception { + return send(panelBuilder(path) + .header("Content-Type", "application/json") + .POST(jsonBody(body)) + .build()); + } + + // ── Helpers ── + + public HttpResponse panelPut(String path, Object body) throws Exception { + return send(panelBuilder(path) + .header("Content-Type", "application/json") + .PUT(jsonBody(body)) + .build()); + } + + public HttpResponse panelPatch(String path, Object body) throws Exception { + return send(panelBuilder(path) + .header("Content-Type", "application/json") + .method("PATCH", jsonBody(body)) + .build()); + } + + public HttpResponse panelDelete(String path) throws Exception { + return send(panelBuilder(path).DELETE().build()); + } + + public HttpResponse publicGet(String path) throws Exception { + return send(serverBuilder(path) + .GET() + .build()); + } + + public HttpResponse publicPost(String path, Object body) throws Exception { + return send(serverBuilder(path) + .header("Content-Type", "application/json") + .POST(jsonBody(body)) + .build()); + } + + public HttpResponse rawGet(String path) throws Exception { + return send(newBuilder(path).GET().build()); + } +} diff --git a/src/test/java/gg/modl/backend/support/DatabaseCleanupTest.java b/src/test/java/gg/modl/backend/support/DatabaseCleanupTest.java index 2f1ce31..fb39898 100644 --- a/src/test/java/gg/modl/backend/support/DatabaseCleanupTest.java +++ b/src/test/java/gg/modl/backend/support/DatabaseCleanupTest.java @@ -1,64 +1,64 @@ -package gg.modl.backend.support; - -import static com.mongodb.client.model.Filters.eq; - -import com.mongodb.client.MongoCursor; -import com.mongodb.client.model.Updates; -import java.util.ArrayList; -import java.util.List; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; - -/** - * One-time cleanup: fix corrupted usernames arrays where test runs - * inserted nested Lists instead of flat Documents. - */ -class DatabaseCleanupTest { - - @Test - void fixCorruptedUsernames() { - Assumptions.assumeTrue(TestDatabase.isAvailable(), "DB not available"); - var db = TestDatabase.getInstance(); - - int fixed = 0; - try (MongoCursor cursor = db.players().find().iterator()) { - while (cursor.hasNext()) { - Document player = cursor.next(); - Object usernamesObj = player.get("usernames"); - if (!(usernamesObj instanceof List usernamesList)) { - continue; - } - - boolean corrupted = false; - List cleanUsernames = new ArrayList<>(); - - for (Object entry : usernamesList) { - if (entry instanceof Document doc) { - cleanUsernames.add(doc); - } else if (entry instanceof List nestedList) { - // Corrupted: extract Documents from nested list - corrupted = true; - for (Object nested : nestedList) { - if (nested instanceof Document doc) { - cleanUsernames.add(doc); - } - } - } - } - - if (corrupted) { - String uuid = player.getString("minecraftUuid"); - System.out.println("Fixing corrupted usernames for player: " + uuid); - db.players().updateOne( - eq("_id", player.get("_id")), - Updates.set("usernames", cleanUsernames) - ); - fixed++; - } - } - } - - System.out.println("Fixed " + fixed + " player(s) with corrupted usernames"); - } -} +package gg.modl.backend.support; + +import static com.mongodb.client.model.Filters.eq; + +import com.mongodb.client.MongoCursor; +import com.mongodb.client.model.Updates; +import java.util.ArrayList; +import java.util.List; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +/** + * One-time cleanup: fix corrupted usernames arrays where test runs + * inserted nested Lists instead of flat Documents. + */ +class DatabaseCleanupTest { + + @Test + void fixCorruptedUsernames() { + Assumptions.assumeTrue(TestDatabase.isAvailable(), "DB not available"); + var db = TestDatabase.getInstance(); + + int fixed = 0; + try (MongoCursor cursor = db.players().find().iterator()) { + while (cursor.hasNext()) { + Document player = cursor.next(); + Object usernamesObj = player.get("usernames"); + if (!(usernamesObj instanceof List usernamesList)) { + continue; + } + + boolean corrupted = false; + List cleanUsernames = new ArrayList<>(); + + for (Object entry : usernamesList) { + if (entry instanceof Document doc) { + cleanUsernames.add(doc); + } else if (entry instanceof List nestedList) { + // Corrupted: extract Documents from nested list + corrupted = true; + for (Object nested : nestedList) { + if (nested instanceof Document doc) { + cleanUsernames.add(doc); + } + } + } + } + + if (corrupted) { + String uuid = player.getString("minecraftUuid"); + System.out.println("Fixing corrupted usernames for player: " + uuid); + db.players().updateOne( + eq("_id", player.get("_id")), + Updates.set("usernames", cleanUsernames) + ); + fixed++; + } + } + } + + System.out.println("Fixed " + fixed + " player(s) with corrupted usernames"); + } +} diff --git a/src/test/java/gg/modl/backend/support/DebugDataTest.java b/src/test/java/gg/modl/backend/support/DebugDataTest.java index d4ed605..3e9e5a6 100644 --- a/src/test/java/gg/modl/backend/support/DebugDataTest.java +++ b/src/test/java/gg/modl/backend/support/DebugDataTest.java @@ -1,80 +1,80 @@ -package gg.modl.backend.support; - -import java.util.List; -import org.bson.Document; -import org.junit.jupiter.api.Assumptions; -import org.junit.jupiter.api.Test; - -class DebugDataTest { - - @Test - void printPlayerData() { - Assumptions.assumeTrue(TestDatabase.isAvailable(), "DB not available"); - var db = TestDatabase.getInstance(); - - // Print first 3 players - var cursor = db.players().find().limit(3).iterator(); - int i = 0; - while (cursor.hasNext()) { - Document doc = cursor.next(); - String uuid = doc.getString("minecraftUuid"); - Object usernamesObj = doc.get("usernames"); - Object usernameField = doc.get("username"); - List punishments = doc.getList("punishments", Document.class); - int punishmentCount = punishments != null ? punishments.size() : 0; - - System.out.println("=== Player " + (i++) + " ==="); - System.out.println(" uuid: " + uuid); - System.out.println(" username field: " + usernameField); - System.out.println(" usernames: " + usernamesObj); - System.out.println(" punishments count: " + punishmentCount); - - if (punishments != null && !punishments.isEmpty()) { - Document p = punishments.get(0); - System.out.println(" first punishment keys: " + p.keySet()); - System.out.println(" first punishment issuerName: " + p.getString("issuerName")); - System.out.println(" first punishment type_ordinal: " + p.get("type_ordinal")); - System.out.println(" first punishment modifications: " + p.get("modifications")); - System.out.println(" first punishment notes: " + p.get("notes")); - System.out.println(" first punishment evidence: " + p.get("evidence")); - System.out.println(" first punishment attachedTicketIds: " + p.get("attachedTicketIds")); - } - } - cursor.close(); - - // Print what TestDataProvider loads - var players = TestDataProvider.getPlayers(); - System.out.println("\n=== TestDataProvider loaded " + players.size() + " players ==="); - for (var p : players) { - System.out.println(" " + p.uuid() + " -> " + p.username()); - } - - // Test a direct API call with the loaded UUID - Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging not available"); - var api = new ApiClient(); - try { - String testUuid = players.get(0).uuid(); - System.out.println("\n=== Testing API with UUID: " + testUuid + " ==="); - - var mcResponse = api.minecraftGet("/v1/minecraft/players/" + testUuid); - System.out.println("Minecraft GET player: " + mcResponse.statusCode()); - if (mcResponse.statusCode() != 200) { - System.out.println(" body: " + mcResponse.body()); - } - - var panelResponse = api.panelGet("/v1/panel/players/" + testUuid); - System.out.println("Panel GET player: " + panelResponse.statusCode()); - if (panelResponse.statusCode() != 200) { - System.out.println(" body: " + panelResponse.body()); - } - - var searchResponse = api.panelGet("/v1/panel/players/punishments/search?q=test&activeOnly=false"); - System.out.println("Panel search punishments: " + searchResponse.statusCode()); - if (searchResponse.statusCode() != 200) { - System.out.println(" body: " + searchResponse.body()); - } - } catch (Exception e) { - System.out.println("API error: " + e.getMessage()); - } - } -} +package gg.modl.backend.support; + +import java.util.List; +import org.bson.Document; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +class DebugDataTest { + + @Test + void printPlayerData() { + Assumptions.assumeTrue(TestDatabase.isAvailable(), "DB not available"); + var db = TestDatabase.getInstance(); + + // Print first 3 players + var cursor = db.players().find().limit(3).iterator(); + int i = 0; + while (cursor.hasNext()) { + Document doc = cursor.next(); + String uuid = doc.getString("minecraftUuid"); + Object usernamesObj = doc.get("usernames"); + Object usernameField = doc.get("username"); + List punishments = doc.getList("punishments", Document.class); + int punishmentCount = punishments != null ? punishments.size() : 0; + + System.out.println("=== Player " + (i++) + " ==="); + System.out.println(" uuid: " + uuid); + System.out.println(" username field: " + usernameField); + System.out.println(" usernames: " + usernamesObj); + System.out.println(" punishments count: " + punishmentCount); + + if (punishments != null && !punishments.isEmpty()) { + Document p = punishments.get(0); + System.out.println(" first punishment keys: " + p.keySet()); + System.out.println(" first punishment issuerName: " + p.getString("issuerName")); + System.out.println(" first punishment type_ordinal: " + p.get("type_ordinal")); + System.out.println(" first punishment modifications: " + p.get("modifications")); + System.out.println(" first punishment notes: " + p.get("notes")); + System.out.println(" first punishment evidence: " + p.get("evidence")); + System.out.println(" first punishment attachedTicketIds: " + p.get("attachedTicketIds")); + } + } + cursor.close(); + + // Print what TestDataProvider loads + var players = TestDataProvider.getPlayers(); + System.out.println("\n=== TestDataProvider loaded " + players.size() + " players ==="); + for (var p : players) { + System.out.println(" " + p.uuid() + " -> " + p.username()); + } + + // Test a direct API call with the loaded UUID + Assumptions.assumeTrue(StagingCredentials.isAvailable(), "Staging not available"); + var api = new ApiClient(); + try { + String testUuid = players.get(0).uuid(); + System.out.println("\n=== Testing API with UUID: " + testUuid + " ==="); + + var mcResponse = api.minecraftGet("/v1/minecraft/players/" + testUuid); + System.out.println("Minecraft GET player: " + mcResponse.statusCode()); + if (mcResponse.statusCode() != 200) { + System.out.println(" body: " + mcResponse.body()); + } + + var panelResponse = api.panelGet("/v1/panel/players/" + testUuid); + System.out.println("Panel GET player: " + panelResponse.statusCode()); + if (panelResponse.statusCode() != 200) { + System.out.println(" body: " + panelResponse.body()); + } + + var searchResponse = api.panelGet("/v1/panel/players/punishments/search?q=test&activeOnly=false"); + System.out.println("Panel search punishments: " + searchResponse.statusCode()); + if (searchResponse.statusCode() != 200) { + System.out.println(" body: " + searchResponse.body()); + } + } catch (Exception e) { + System.out.println("API error: " + e.getMessage()); + } + } +} diff --git a/src/test/java/gg/modl/backend/support/JsonHelper.java b/src/test/java/gg/modl/backend/support/JsonHelper.java index e8520f0..c95ce2c 100644 --- a/src/test/java/gg/modl/backend/support/JsonHelper.java +++ b/src/test/java/gg/modl/backend/support/JsonHelper.java @@ -1,49 +1,49 @@ -package gg.modl.backend.support; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import java.net.http.HttpResponse; - -public final class JsonHelper { - - private static final Gson GSON = new GsonBuilder().create(); - - private JsonHelper() {} - - public static JsonArray parseArray(String json) { - return JsonParser.parseString(json).getAsJsonArray(); - } - - public static String toJson(Object obj) { - return GSON.toJson(obj); - } - - public static void assertStatus(HttpResponse response, int expectedCode) { - assertEquals(expectedCode, response.statusCode(), - () -> "Expected status " + expectedCode + " but got " + response.statusCode() - + " | body: " + truncate(response.body(), 500)); - } - - private static String truncate(String s, int max) { - if (s == null) { - return ""; - } - return s.length() <= max ? s : s.substring(0, max) + "..."; - } - - public static void assertJsonHas(HttpResponse response, String field) { - JsonObject json = parseObject(response.body()); - assertTrue(json.has(field), - () -> "Expected JSON to have field '" + field + "' but got: " + truncate(response.body(), 500)); - } - - public static JsonObject parseObject(String json) { - return JsonParser.parseString(json).getAsJsonObject(); - } -} +package gg.modl.backend.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.net.http.HttpResponse; + +public final class JsonHelper { + + private static final Gson GSON = new GsonBuilder().create(); + + private JsonHelper() {} + + public static JsonArray parseArray(String json) { + return JsonParser.parseString(json).getAsJsonArray(); + } + + public static String toJson(Object obj) { + return GSON.toJson(obj); + } + + public static void assertStatus(HttpResponse response, int expectedCode) { + assertEquals(expectedCode, response.statusCode(), + () -> "Expected status " + expectedCode + " but got " + response.statusCode() + + " | body: " + truncate(response.body(), 500)); + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max) + "..."; + } + + public static void assertJsonHas(HttpResponse response, String field) { + JsonObject json = parseObject(response.body()); + assertTrue(json.has(field), + () -> "Expected JSON to have field '" + field + "' but got: " + truncate(response.body(), 500)); + } + + public static JsonObject parseObject(String json) { + return JsonParser.parseString(json).getAsJsonObject(); + } +} diff --git a/src/test/java/gg/modl/backend/support/TestDataProvider.java b/src/test/java/gg/modl/backend/support/TestDataProvider.java index 4bbf468..df8ec9e 100644 --- a/src/test/java/gg/modl/backend/support/TestDataProvider.java +++ b/src/test/java/gg/modl/backend/support/TestDataProvider.java @@ -1,270 +1,270 @@ -package gg.modl.backend.support; - -import static com.mongodb.client.model.Filters.*; - -import com.mongodb.client.MongoCursor; -import com.mongodb.client.model.Updates; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import org.bson.Document; - -public final class TestDataProvider { - - // Defaults (used when MongoDB is not available) - private static final String DEFAULT_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; - private static final String DEFAULT_USERNAME = "Notch"; - private static final int DEFAULT_TYPE_ORDINAL = 14; - - private static volatile boolean initialized = false; - private static List players; - private static PlayerInfo playerWithPunishments; - private static PlayerInfo playerWithoutPunishments; - private static List punishmentTypes; - private static List staffMembers; - private static List rolesList; - - private TestDataProvider() {} - - public static List getPlayers() { - initialize(); - return Collections.unmodifiableList(players); - } - - private static synchronized void initialize() { - if (initialized) { - return; - } - - if (!TestDatabase.isAvailable()) { - loadDefaults(); - initialized = true; - return; - } - - try { - loadFromDatabase(); - } catch (Throwable e) { - System.err.println("[TestDataProvider] Failed to load from DB, using defaults: " + e.getMessage()); - loadDefaults(); - } - initialized = true; - } - - private static void loadDefaults() { - players = List.of(new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME)); - playerWithPunishments = new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME); - playerWithoutPunishments = new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME); - punishmentTypes = List.of(new PunishmentTypeInfo(DEFAULT_TYPE_ORDINAL, "Default", "BAN")); - staffMembers = List.of(); - rolesList = List.of(); - } - - private static void loadFromDatabase() { - TestDatabase db = TestDatabase.getInstance(); - - // Load players - players = new ArrayList<>(); - playerWithPunishments = null; - playerWithoutPunishments = null; - - try (MongoCursor cursor = db.players().find().limit(20).iterator()) { - while (cursor.hasNext()) { - Document doc = cursor.next(); - PlayerInfo info = toPlayerInfo(doc); - if (info == null) { - continue; - } - - players.add(info); - - List punishments = doc.getList("punishments", Document.class); - if (punishments != null && !punishments.isEmpty()) { - if (playerWithPunishments == null) { - playerWithPunishments = info; - } - } else { - if (playerWithoutPunishments == null) { - playerWithoutPunishments = info; - } - } - - if (players.size() >= 5 && playerWithPunishments != null && playerWithoutPunishments != null) { - break; - } - } - } - - if (players.isEmpty()) { - players = List.of(new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME)); - } - if (playerWithPunishments == null) { - playerWithPunishments = toPlayerInfo(db.players().find(elemMatch("punishments", new Document())).first()); - } - if (playerWithoutPunishments == null) { - playerWithoutPunishments = toPlayerInfo(db.players().find(or(exists("punishments", false), size("punishments", 0))).first()); - } - if (playerWithPunishments == null) { - System.err.println("[TestDataProvider] WARNING: no player with punishments found in staging DB; falling back to players.get(0) — tests requiring a punished player may be unreliable."); - playerWithPunishments = players.get(0); - } - if (playerWithoutPunishments == null) { - System.err.println("[TestDataProvider] WARNING: no player without punishments found in staging DB; falling back to players.get(0)."); - playerWithoutPunishments = players.get(0); - } - - // Load punishment types from settings - punishmentTypes = new ArrayList<>(); - Document punishmentSettings = db.findSettingsByType("punishment_types"); - if (punishmentSettings != null) { - Object dataObj = punishmentSettings.get("data"); - if (dataObj instanceof Document data) { - List types = data.getList("types", Document.class); - if (types != null) { - for (Document type : types) { - Integer ordinal = type.getInteger("ordinal"); - String name = type.getString("name"); - String category = type.getString("category"); - if (ordinal != null && name != null) { - punishmentTypes.add(new PunishmentTypeInfo(ordinal, name, category != null ? category : "BAN")); - } - } - } - } - } - if (punishmentTypes.isEmpty()) { - punishmentTypes = List.of(new PunishmentTypeInfo(DEFAULT_TYPE_ORDINAL, "Default", "BAN")); - } - - // Load staff members - staffMembers = new ArrayList<>(); - try (MongoCursor cursor = db.staff().find().limit(10).iterator()) { - while (cursor.hasNext()) { - Document doc = cursor.next(); - Object idObj = doc.get("_id"); - String id = idObj != null ? idObj.toString() : null; - String username = doc.getString("username"); - String role = doc.getString("role"); - if (id != null && username != null) { - staffMembers.add(new StaffInfo(id, username, role != null ? role : "")); - } - } - } - - // Load roles - rolesList = new ArrayList<>(); - try (MongoCursor cursor = db.roles().find().limit(10).iterator()) { - while (cursor.hasNext()) { - Document doc = cursor.next(); - Object idObj = doc.get("_id"); - String id = idObj != null ? idObj.toString() : null; - String name = doc.getString("name"); - if (id != null && name != null) { - rolesList.add(new RoleInfo(id, name)); - } - } - } - } - - // ── Public accessors ── - - private static PlayerInfo toPlayerInfo(Document doc) { - if (doc == null) { - return null; - } - String uuid = doc.getString("minecraftUuid"); - String username = lastUsername(doc.get("usernames")); - if (uuid == null || username == null) { - return null; - } - return new PlayerInfo(uuid, username); - } - - private static String lastUsername(Object usernamesObj) { - if (usernamesObj instanceof List list && !list.isEmpty()) { - for (int i = list.size() - 1; i >= 0; i--) { - if (list.get(i) instanceof Document d) { - String u = d.getString("username"); - if (u != null) { - return u; - } - } - } - } - return null; - } - - /** - * Repairs corrupted (nested-array) {@code usernames} entries in the shared staging players - * collection. This MUTATES shared staging data, so it is opt-in and must NEVER run as a side - * effect of a read path. The query is server-filtered to only touch genuinely-corrupt documents. - */ - public static synchronized void repairCorruptedUsernames() { - if (!TestDatabase.isAvailable()) { - return; - } - TestDatabase db = TestDatabase.getInstance(); - try (MongoCursor cursor = db.players().find(elemMatch("usernames", new Document("$type", "array"))).iterator()) { - while (cursor.hasNext()) { - Document player = cursor.next(); - Object usernamesObj = player.get("usernames"); - if (!(usernamesObj instanceof List usernamesList)) { - continue; - } - - boolean corrupted = false; - List clean = new ArrayList<>(); - for (Object entry : usernamesList) { - if (entry instanceof Document doc) { - clean.add(doc); - } else if (entry instanceof List nested) { - corrupted = true; - for (Object n : nested) { - if (n instanceof Document doc) { - clean.add(doc); - } - } - } - } - if (corrupted) { - db.players().updateOne(eq("_id", player.get("_id")), Updates.set("usernames", clean)); - } - } - } - } - - public static PlayerInfo getPlayerWithPunishments() { - initialize(); - return playerWithPunishments; - } - - public static PlayerInfo getPlayerWithoutPunishments() { - initialize(); - return playerWithoutPunishments; - } - - public static List getPunishmentTypes() { - initialize(); - return Collections.unmodifiableList(punishmentTypes); - } - - public static List getStaffMembers() { - initialize(); - return Collections.unmodifiableList(staffMembers); - } - - public static List getRoles() { - initialize(); - return Collections.unmodifiableList(rolesList); - } - - // ── Record types ── - - public record PlayerInfo(String uuid, String username) {} - - public record PunishmentTypeInfo(int ordinal, String name, String category) {} - - public record StaffInfo(String id, String username, String role) {} - - public record RoleInfo(String id, String name) {} -} +package gg.modl.backend.support; + +import static com.mongodb.client.model.Filters.*; + +import com.mongodb.client.MongoCursor; +import com.mongodb.client.model.Updates; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.bson.Document; + +public final class TestDataProvider { + + // Defaults (used when MongoDB is not available) + private static final String DEFAULT_UUID = "069a79f4-44e9-4726-a5be-fca90e38aaf5"; + private static final String DEFAULT_USERNAME = "Notch"; + private static final int DEFAULT_TYPE_ORDINAL = 14; + + private static volatile boolean initialized = false; + private static List players; + private static PlayerInfo playerWithPunishments; + private static PlayerInfo playerWithoutPunishments; + private static List punishmentTypes; + private static List staffMembers; + private static List rolesList; + + private TestDataProvider() {} + + public static List getPlayers() { + initialize(); + return Collections.unmodifiableList(players); + } + + private static synchronized void initialize() { + if (initialized) { + return; + } + + if (!TestDatabase.isAvailable()) { + loadDefaults(); + initialized = true; + return; + } + + try { + loadFromDatabase(); + } catch (Throwable e) { + System.err.println("[TestDataProvider] Failed to load from DB, using defaults: " + e.getMessage()); + loadDefaults(); + } + initialized = true; + } + + private static void loadDefaults() { + players = List.of(new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME)); + playerWithPunishments = new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME); + playerWithoutPunishments = new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME); + punishmentTypes = List.of(new PunishmentTypeInfo(DEFAULT_TYPE_ORDINAL, "Default", "BAN")); + staffMembers = List.of(); + rolesList = List.of(); + } + + private static void loadFromDatabase() { + TestDatabase db = TestDatabase.getInstance(); + + // Load players + players = new ArrayList<>(); + playerWithPunishments = null; + playerWithoutPunishments = null; + + try (MongoCursor cursor = db.players().find().limit(20).iterator()) { + while (cursor.hasNext()) { + Document doc = cursor.next(); + PlayerInfo info = toPlayerInfo(doc); + if (info == null) { + continue; + } + + players.add(info); + + List punishments = doc.getList("punishments", Document.class); + if (punishments != null && !punishments.isEmpty()) { + if (playerWithPunishments == null) { + playerWithPunishments = info; + } + } else { + if (playerWithoutPunishments == null) { + playerWithoutPunishments = info; + } + } + + if (players.size() >= 5 && playerWithPunishments != null && playerWithoutPunishments != null) { + break; + } + } + } + + if (players.isEmpty()) { + players = List.of(new PlayerInfo(DEFAULT_UUID, DEFAULT_USERNAME)); + } + if (playerWithPunishments == null) { + playerWithPunishments = toPlayerInfo(db.players().find(elemMatch("punishments", new Document())).first()); + } + if (playerWithoutPunishments == null) { + playerWithoutPunishments = toPlayerInfo(db.players().find(or(exists("punishments", false), size("punishments", 0))).first()); + } + if (playerWithPunishments == null) { + System.err.println("[TestDataProvider] WARNING: no player with punishments found in staging DB; falling back to players.get(0) — tests requiring a punished player may be unreliable."); + playerWithPunishments = players.get(0); + } + if (playerWithoutPunishments == null) { + System.err.println("[TestDataProvider] WARNING: no player without punishments found in staging DB; falling back to players.get(0)."); + playerWithoutPunishments = players.get(0); + } + + // Load punishment types from settings + punishmentTypes = new ArrayList<>(); + Document punishmentSettings = db.findSettingsByType("punishment_types"); + if (punishmentSettings != null) { + Object dataObj = punishmentSettings.get("data"); + if (dataObj instanceof Document data) { + List types = data.getList("types", Document.class); + if (types != null) { + for (Document type : types) { + Integer ordinal = type.getInteger("ordinal"); + String name = type.getString("name"); + String category = type.getString("category"); + if (ordinal != null && name != null) { + punishmentTypes.add(new PunishmentTypeInfo(ordinal, name, category != null ? category : "BAN")); + } + } + } + } + } + if (punishmentTypes.isEmpty()) { + punishmentTypes = List.of(new PunishmentTypeInfo(DEFAULT_TYPE_ORDINAL, "Default", "BAN")); + } + + // Load staff members + staffMembers = new ArrayList<>(); + try (MongoCursor cursor = db.staff().find().limit(10).iterator()) { + while (cursor.hasNext()) { + Document doc = cursor.next(); + Object idObj = doc.get("_id"); + String id = idObj != null ? idObj.toString() : null; + String username = doc.getString("username"); + String role = doc.getString("role"); + if (id != null && username != null) { + staffMembers.add(new StaffInfo(id, username, role != null ? role : "")); + } + } + } + + // Load roles + rolesList = new ArrayList<>(); + try (MongoCursor cursor = db.roles().find().limit(10).iterator()) { + while (cursor.hasNext()) { + Document doc = cursor.next(); + Object idObj = doc.get("_id"); + String id = idObj != null ? idObj.toString() : null; + String name = doc.getString("name"); + if (id != null && name != null) { + rolesList.add(new RoleInfo(id, name)); + } + } + } + } + + // ── Public accessors ── + + private static PlayerInfo toPlayerInfo(Document doc) { + if (doc == null) { + return null; + } + String uuid = doc.getString("minecraftUuid"); + String username = lastUsername(doc.get("usernames")); + if (uuid == null || username == null) { + return null; + } + return new PlayerInfo(uuid, username); + } + + private static String lastUsername(Object usernamesObj) { + if (usernamesObj instanceof List list && !list.isEmpty()) { + for (int i = list.size() - 1; i >= 0; i--) { + if (list.get(i) instanceof Document d) { + String u = d.getString("username"); + if (u != null) { + return u; + } + } + } + } + return null; + } + + /** + * Repairs corrupted (nested-array) {@code usernames} entries in the shared staging players + * collection. This MUTATES shared staging data, so it is opt-in and must NEVER run as a side + * effect of a read path. The query is server-filtered to only touch genuinely-corrupt documents. + */ + public static synchronized void repairCorruptedUsernames() { + if (!TestDatabase.isAvailable()) { + return; + } + TestDatabase db = TestDatabase.getInstance(); + try (MongoCursor cursor = db.players().find(elemMatch("usernames", new Document("$type", "array"))).iterator()) { + while (cursor.hasNext()) { + Document player = cursor.next(); + Object usernamesObj = player.get("usernames"); + if (!(usernamesObj instanceof List usernamesList)) { + continue; + } + + boolean corrupted = false; + List clean = new ArrayList<>(); + for (Object entry : usernamesList) { + if (entry instanceof Document doc) { + clean.add(doc); + } else if (entry instanceof List nested) { + corrupted = true; + for (Object n : nested) { + if (n instanceof Document doc) { + clean.add(doc); + } + } + } + } + if (corrupted) { + db.players().updateOne(eq("_id", player.get("_id")), Updates.set("usernames", clean)); + } + } + } + } + + public static PlayerInfo getPlayerWithPunishments() { + initialize(); + return playerWithPunishments; + } + + public static PlayerInfo getPlayerWithoutPunishments() { + initialize(); + return playerWithoutPunishments; + } + + public static List getPunishmentTypes() { + initialize(); + return Collections.unmodifiableList(punishmentTypes); + } + + public static List getStaffMembers() { + initialize(); + return Collections.unmodifiableList(staffMembers); + } + + public static List getRoles() { + initialize(); + return Collections.unmodifiableList(rolesList); + } + + // ── Record types ── + + public record PlayerInfo(String uuid, String username) {} + + public record PunishmentTypeInfo(int ordinal, String name, String category) {} + + public record StaffInfo(String id, String username, String role) {} + + public record RoleInfo(String id, String name) {} +} diff --git a/src/test/java/gg/modl/backend/support/TestDatabase.java b/src/test/java/gg/modl/backend/support/TestDatabase.java index 797fe61..00427e1 100644 --- a/src/test/java/gg/modl/backend/support/TestDatabase.java +++ b/src/test/java/gg/modl/backend/support/TestDatabase.java @@ -1,166 +1,166 @@ -package gg.modl.backend.support; - -import static com.mongodb.client.model.Filters.eq; - -import com.mongodb.client.MongoClient; -import com.mongodb.client.MongoClients; -import com.mongodb.client.MongoCollection; -import com.mongodb.client.MongoDatabase; -import java.util.List; -import org.bson.Document; -import org.bson.types.ObjectId; - -public final class TestDatabase { - - private final MongoClient client; - private final MongoDatabase serverDb; - private static volatile TestDatabase instance; - - private TestDatabase(String uri, String domain) { - this.client = MongoClients.create(uri); - // Extract subdomain from full domain (e.g. "byteful.modl.gg" -> "byteful") - String subdomain = domain.contains(".") ? domain.substring(0, domain.indexOf('.')) : domain; - this.serverDb = client.getDatabase("server_" + subdomain); - } - - public static TestDatabase getInstance() { - if (instance == null) { - synchronized (TestDatabase.class) { - if (instance == null) { - String uri = StagingCredentials.mongoUri(); - String domain = StagingCredentials.serverDomain(); - instance = new TestDatabase(uri, domain); - } - } - } - return instance; - } - - public static boolean isAvailable() { - String uri = StagingCredentials.mongoUri(); - return uri != null && !uri.isBlank(); - } - - // ── Collection accessors ── - - public Document findPunishmentInPlayer(String playerUuid, String punishmentId) { - Document player = findPlayerByUuid(playerUuid); - if (player == null) { - return null; - } - List punishments = player.getList("punishments", Document.class); - if (punishments == null) { - return null; - } - for (Document p : punishments) { - String id = null; - Object idObj = p.get("_id"); - if (idObj instanceof ObjectId oid) { - id = oid.toHexString(); - } else if (idObj instanceof String s) { - id = s; - } - // Also check the "id" field - if (punishmentId.equals(id) || punishmentId.equals(p.getString("id"))) { - return p; - } - } - return null; - } - - public Document findPlayerByUuid(String uuid) { - return players().find(eq("minecraftUuid", uuid)).first(); - } - - public MongoCollection players() {return serverDb.getCollection("players");} - - public Document findTicketById(String ticketId) { - // Try string _id first, then ObjectId - Document doc = tickets().find(eq("_id", ticketId)).first(); - if (doc == null) { - try { - doc = tickets().find(eq("_id", new ObjectId(ticketId))).first(); - } catch (IllegalArgumentException ignored) { - } - } - return doc; - } - - public MongoCollection tickets() {return serverDb.getCollection("tickets");} - - public Document findStaffByUsername(String username) { - return staff().find(eq("username", username)).first(); - } - - public MongoCollection staff() {return serverDb.getCollection("staffs");} - - public Document findRoleById(String roleId) { - Document doc = roles().find(eq("_id", roleId)).first(); - if (doc == null) { - try { - doc = roles().find(eq("_id", new ObjectId(roleId))).first(); - } catch (IllegalArgumentException ignored) { - } - } - if (doc == null) { - doc = roles().find(eq("id", roleId)).first(); - } - return doc; - } - - // ── Query helpers ── - - public MongoCollection roles() {return serverDb.getCollection("staffroles");} - - public Document findSettingsByType(String type) { - return settings().find(eq("type", type)).first(); - } - - public MongoCollection settings() {return serverDb.getCollection("settings");} - - public Document findHomepageCardById(String cardId) { - Document doc = homepageCards().find(eq("_id", cardId)).first(); - if (doc == null) { - try { - doc = homepageCards().find(eq("_id", new ObjectId(cardId))).first(); - } catch (IllegalArgumentException ignored) { - } - } - return doc; - } - - public MongoCollection homepageCards() {return serverDb.getCollection("homepagecards");} - - public Document findKbCategoryById(String categoryId) { - Document doc = kbCategories().find(eq("_id", categoryId)).first(); - if (doc == null) { - try { - doc = kbCategories().find(eq("_id", new ObjectId(categoryId))).first(); - } catch (IllegalArgumentException ignored) { - } - } - return doc; - } - - public MongoCollection kbCategories() {return serverDb.getCollection("knowledgebasecategories");} - - public Document findKbArticleById(String articleId) { - Document doc = kbArticles().find(eq("_id", articleId)).first(); - if (doc == null) { - try { - doc = kbArticles().find(eq("_id", new ObjectId(articleId))).first(); - } catch (IllegalArgumentException ignored) { - } - } - return doc; - } - - public MongoCollection kbArticles() {return serverDb.getCollection("knowledgebasearticles");} - - public void close() { - client.close(); - synchronized (TestDatabase.class) { - instance = null; - } - } -} +package gg.modl.backend.support; + +import static com.mongodb.client.model.Filters.eq; + +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import java.util.List; +import org.bson.Document; +import org.bson.types.ObjectId; + +public final class TestDatabase { + + private final MongoClient client; + private final MongoDatabase serverDb; + private static volatile TestDatabase instance; + + private TestDatabase(String uri, String domain) { + this.client = MongoClients.create(uri); + // Extract subdomain from full domain (e.g. "byteful.modl.gg" -> "byteful") + String subdomain = domain.contains(".") ? domain.substring(0, domain.indexOf('.')) : domain; + this.serverDb = client.getDatabase("server_" + subdomain); + } + + public static TestDatabase getInstance() { + if (instance == null) { + synchronized (TestDatabase.class) { + if (instance == null) { + String uri = StagingCredentials.mongoUri(); + String domain = StagingCredentials.serverDomain(); + instance = new TestDatabase(uri, domain); + } + } + } + return instance; + } + + public static boolean isAvailable() { + String uri = StagingCredentials.mongoUri(); + return uri != null && !uri.isBlank(); + } + + // ── Collection accessors ── + + public Document findPunishmentInPlayer(String playerUuid, String punishmentId) { + Document player = findPlayerByUuid(playerUuid); + if (player == null) { + return null; + } + List punishments = player.getList("punishments", Document.class); + if (punishments == null) { + return null; + } + for (Document p : punishments) { + String id = null; + Object idObj = p.get("_id"); + if (idObj instanceof ObjectId oid) { + id = oid.toHexString(); + } else if (idObj instanceof String s) { + id = s; + } + // Also check the "id" field + if (punishmentId.equals(id) || punishmentId.equals(p.getString("id"))) { + return p; + } + } + return null; + } + + public Document findPlayerByUuid(String uuid) { + return players().find(eq("minecraftUuid", uuid)).first(); + } + + public MongoCollection players() {return serverDb.getCollection("players");} + + public Document findTicketById(String ticketId) { + // Try string _id first, then ObjectId + Document doc = tickets().find(eq("_id", ticketId)).first(); + if (doc == null) { + try { + doc = tickets().find(eq("_id", new ObjectId(ticketId))).first(); + } catch (IllegalArgumentException ignored) { + } + } + return doc; + } + + public MongoCollection tickets() {return serverDb.getCollection("tickets");} + + public Document findStaffByUsername(String username) { + return staff().find(eq("username", username)).first(); + } + + public MongoCollection staff() {return serverDb.getCollection("staffs");} + + public Document findRoleById(String roleId) { + Document doc = roles().find(eq("_id", roleId)).first(); + if (doc == null) { + try { + doc = roles().find(eq("_id", new ObjectId(roleId))).first(); + } catch (IllegalArgumentException ignored) { + } + } + if (doc == null) { + doc = roles().find(eq("id", roleId)).first(); + } + return doc; + } + + // ── Query helpers ── + + public MongoCollection roles() {return serverDb.getCollection("staffroles");} + + public Document findSettingsByType(String type) { + return settings().find(eq("type", type)).first(); + } + + public MongoCollection settings() {return serverDb.getCollection("settings");} + + public Document findHomepageCardById(String cardId) { + Document doc = homepageCards().find(eq("_id", cardId)).first(); + if (doc == null) { + try { + doc = homepageCards().find(eq("_id", new ObjectId(cardId))).first(); + } catch (IllegalArgumentException ignored) { + } + } + return doc; + } + + public MongoCollection homepageCards() {return serverDb.getCollection("homepagecards");} + + public Document findKbCategoryById(String categoryId) { + Document doc = kbCategories().find(eq("_id", categoryId)).first(); + if (doc == null) { + try { + doc = kbCategories().find(eq("_id", new ObjectId(categoryId))).first(); + } catch (IllegalArgumentException ignored) { + } + } + return doc; + } + + public MongoCollection kbCategories() {return serverDb.getCollection("knowledgebasecategories");} + + public Document findKbArticleById(String articleId) { + Document doc = kbArticles().find(eq("_id", articleId)).first(); + if (doc == null) { + try { + doc = kbArticles().find(eq("_id", new ObjectId(articleId))).first(); + } catch (IllegalArgumentException ignored) { + } + } + return doc; + } + + public MongoCollection kbArticles() {return serverDb.getCollection("knowledgebasearticles");} + + public void close() { + client.close(); + synchronized (TestDatabase.class) { + instance = null; + } + } +} diff --git a/src/test/java/gg/modl/backend/ticket/service/TicketServiceTest.java b/src/test/java/gg/modl/backend/ticket/service/TicketServiceTest.java index 59a0b67..e08f22a 100644 --- a/src/test/java/gg/modl/backend/ticket/service/TicketServiceTest.java +++ b/src/test/java/gg/modl/backend/ticket/service/TicketServiceTest.java @@ -1,371 +1,371 @@ -package gg.modl.backend.ticket.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; -import static org.mockito.ArgumentMatchers.anyInt; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.server.data.Server; -import gg.modl.backend.server.data.ServerPlan; -import gg.modl.backend.settings.data.QuickResponseSettings; -import gg.modl.backend.settings.service.QuickResponseSettingsService; -import gg.modl.backend.settings.service.TicketFormSettingsService; -import gg.modl.backend.ticket.data.Ticket; -import gg.modl.backend.ticket.data.TicketCategory; -import gg.modl.backend.ticket.data.TicketPriority; -import gg.modl.backend.ticket.data.TicketReply; -import gg.modl.backend.ticket.data.TicketStatus; -import gg.modl.backend.ticket.dto.request.CreateTicketRequest; -import gg.modl.backend.ticket.dto.request.DismissReportRequest; -import gg.modl.backend.ticket.dto.request.MinecraftClaimTicketRequest; -import gg.modl.backend.ticket.dto.request.MinecraftCreateTicketRequest; -import gg.modl.backend.ticket.dto.request.QuickResponseRequest; -import gg.modl.backend.ticket.dto.request.SubmitTicketFormRequest; -import gg.modl.backend.settings.service.WebhookSettingsService; -import java.util.ArrayList; -import java.util.Date; -import java.util.HashMap; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class TicketServiceTest { - - @Mock - private TicketMongoRepository ticketRepository; - - @Mock - private StaffMongoRepository staffRepository; - - @Mock - private QuickResponseSettingsService quickResponseSettingsService; - - @Mock - private TicketFormSettingsService ticketFormSettingsService; - - @Mock - private TicketNotificationService notificationService; - - @Mock - private TicketIdGenerator ticketIdGenerator; - - private final TicketContentService contentService = new TicketContentService(); - - @Mock - private WebhookSettingsService webhookSettingsService; - - private TicketService ticketService; - private MinecraftTicketService minecraftTicketService; - - @BeforeEach - void setUp() { - ticketService = new TicketService(ticketRepository, staffRepository, quickResponseSettingsService, ticketFormSettingsService, notificationService, ticketIdGenerator, contentService, webhookSettingsService); - minecraftTicketService = new MinecraftTicketService(ticketRepository, notificationService, ticketIdGenerator); - } - - @Test - void createMinecraftTicketMapsPluginTypeAndPersistsReply() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(ticketIdGenerator.insertWithUniqueId(any(Server.class), any(String.class), any(Ticket.class))) - .thenAnswer(invocation -> { - Ticket t = invocation.getArgument(2); - if (t.getId() == null) { - t.setId("CHAT-123456"); - } - return t; - }); - - Ticket ticket = minecraftTicketService.createMinecraftTicket(server, new MinecraftCreateTicketRequest( - "uuid-1", - "PlayerOne", - "chat", - "Chat report", - "reported bad chat", - "uuid-2", - "PlayerTwo", - List.of("hello world"), - List.of("report"), - null, - "survival", - null - )); - - assertEquals(TicketCategory.CHAT, ticket.getType()); - assertEquals(TicketPriority.NORMAL, ticket.getPriority()); - assertEquals(1, ticket.getReplies().size()); - assertEquals("reported bad chat", ticket.getReplies().get(0).getContent()); - assertEquals(1, ticket.getChatMessages().size()); - } - - @Test - void createTicketAcceptsLegacyTypeSpacingAndPriorityAliases() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(ticketIdGenerator.insertWithUniqueId(any(Server.class), any(String.class), any(Ticket.class))) - .thenAnswer(invocation -> { - Ticket t = invocation.getArgument(2); - if (t.getId() == null) { - t.setId("STAFF-123456"); - } - return t; - }); - - ticketService.createTicket(server, new CreateTicketRequest( - "staff application", - "", - "Legacy alias submit", - null, - "Applicant", - null, - null, - null, - null, - null, - null, - List.of("legacy"), - "medium", - null, - null, - null - )); - - ArgumentCaptor savedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); - verify(ticketIdGenerator).insertWithUniqueId(any(Server.class), any(String.class), savedTicketCaptor.capture()); - Ticket savedTicket = savedTicketCaptor.getValue(); - - assertEquals(TicketCategory.APPLICATION, savedTicket.getType()); - assertEquals(TicketPriority.NORMAL, savedTicket.getPriority()); - assertEquals(TicketStatus.UNFINISHED, savedTicket.getStatus()); - } - - @Test - void claimMinecraftTicketRenamesMatchingNonStaffReplies() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Ticket ticket = Ticket.builder() - .id("SUPPORT-123456") - .creatorName("Old Web User") - .replies(new ArrayList<>(List.of( - TicketReply.builder().id("1").name("Old Web User").staff(false).content("first").created(new Date()).build(), - TicketReply.builder().id("2").name("Staff").staff(true).content("staff").created(new Date()).build() - ))) - .build(); - - when(ticketRepository.findById(server, "SUPPORT-123456")).thenReturn(Optional.of(ticket)); - when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) - .thenAnswer(invocation -> invocation.getArgument(1)); - - MinecraftTicketService.MinecraftTicketClaimResult result = minecraftTicketService.claimMinecraftTicket( - server, - "SUPPORT-123456", - new MinecraftClaimTicketRequest("uuid-new", "VerifiedPlayer") - ); - - assertEquals(MinecraftTicketService.MinecraftTicketClaimStatus.SUCCESS, result.status()); - assertEquals("uuid-new", result.ticket().getCreatorUuid()); - assertEquals("VerifiedPlayer", result.ticket().getCreatorName()); - assertEquals("VerifiedPlayer", result.ticket().getReplies().get(0).getName()); - assertEquals("Staff", result.ticket().getReplies().get(1).getName()); - assertNotNull(result.ticket().getUpdatedAt()); - - ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); - verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); - assertTrue(updatedTicketCaptor.getValue().getUpdatedAt() != null); - } - - @Test - void dismissMinecraftReportClosesTicketAndAppendsStaffReply() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Ticket ticket = Ticket.builder() - .id("REPORT-1") - .data(new HashMap<>()) - .replies(new ArrayList<>()) - .build(); - - when(ticketRepository.findById(server, "REPORT-1")).thenReturn(Optional.of(ticket)); - when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) - .thenAnswer(invocation -> invocation.getArgument(1)); - - MinecraftTicketService.ReportOperationResult result = minecraftTicketService.dismissMinecraftReport( - server, - "REPORT-1", - new DismissReportRequest("Moderator", "Insufficient evidence") - ); - - assertEquals(MinecraftTicketService.ReportOperationStatus.SUCCESS, result.status()); - - ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); - verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); - Ticket updatedTicket = updatedTicketCaptor.getValue(); - assertEquals(TicketStatus.CLOSED, updatedTicket.getStatus()); - assertTrue(updatedTicket.isLocked()); - assertEquals(1, updatedTicket.getReplies().size()); - assertEquals("Moderator", updatedTicket.getReplies().get(0).getName()); - assertEquals("Insufficient evidence", updatedTicket.getData().get("dismissReason")); - assertEquals("Moderator", updatedTicket.getData().get("dismissedBy")); - verify(notificationService).notifyTicketReply(any(Server.class), any(Ticket.class), any(TicketReply.class)); - } - - @Test - void processQuickResponseClosesTicketThroughRepositorySave() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Ticket ticket = Ticket.builder() - .id("SUPPORT-1") - .replies(new ArrayList<>()) - .notes(new ArrayList<>()) - .build(); - QuickResponseSettings settings = QuickResponseSettings.builder().build(); - QuickResponseSettings.Action action = QuickResponseSettings.Action.builder() - .id("close") - .name("Close") - .message("Resolved") - .closeTicket(true) - .appealAction("none") - .build(); - - when(ticketRepository.findById(server, "SUPPORT-1")).thenReturn(Optional.of(ticket)); - when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) - .thenAnswer(invocation -> invocation.getArgument(1)); - when(quickResponseSettingsService.getQuickResponseSettings(server)).thenReturn(settings); - when(quickResponseSettingsService.findAction(settings, "general", "close")).thenReturn(action); - - var result = ticketService.processQuickResponse( - server, - "SUPPORT-1", - new QuickResponseRequest("close", "general", null, null, null, null), - "Moderator" - ); - - assertTrue(result.success()); - - ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); - verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); - Ticket updatedTicket = updatedTicketCaptor.getValue(); - assertEquals(TicketStatus.CLOSED, updatedTicket.getStatus()); - assertTrue(updatedTicket.isLocked()); - assertEquals(1, updatedTicket.getReplies().size()); - assertEquals("Moderator", updatedTicket.getReplies().get(0).getName()); - verify(notificationService).notifyTicketReply(any(Server.class), any(Ticket.class), any(TicketReply.class)); - verify(notificationService).notifyTicketClosed(any(Server.class), any(Ticket.class)); - } - - @Test - void submitTicketFormPromotesUnfinishedTicketAndAddsInitialReply() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Ticket ticket = Ticket.builder() - .id("SUPPORT-2") - .type(TicketCategory.SUPPORT) - .status(TicketStatus.UNFINISHED) - .creatorName("PlayerOne") - .replies(new ArrayList<>()) - .notes(new ArrayList<>()) - .data(new HashMap<>()) - .build(); - - when(ticketRepository.findById(server, "SUPPORT-2")).thenReturn(Optional.of(ticket)); - when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) - .thenAnswer(invocation -> invocation.getArgument(1)); - - var response = ticketService.submitTicketForm( - server, - "SUPPORT-2", - new SubmitTicketFormRequest( - "Updated subject", - "player@example.com", - java.util.Map.of("issue_type", "Bug report", "emailAuthEnabled", true), - List.of(), - "creator-1", - null - ), - true - ); - - assertNotNull(response); - - ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); - verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); - Ticket updatedTicket = updatedTicketCaptor.getValue(); - assertEquals(TicketStatus.OPEN, updatedTicket.getStatus()); - assertEquals("Updated subject", updatedTicket.getSubject()); - assertTrue(updatedTicket.isEmailAuthEnabled()); - assertEquals(true, updatedTicket.getData().get("emailAuthEnabled")); - assertEquals(true, updatedTicket.getFormData().get("emailAuthEnabled")); - assertEquals("player@example.com", updatedTicket.getData().get("creatorEmail")); - assertEquals("creator-1", updatedTicket.getData().get("creatorIdentifier")); - assertEquals(1, updatedTicket.getReplies().size()); - assertTrue(updatedTicket.getReplies().get(0).getContent().contains("Issue Type")); - } - - @Test - void getMinecraftTicketsByCreatorLowercasesUuidBeforeQueryingRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(ticketRepository.findRecentByCreator(any(Server.class), any(), anyInt())).thenReturn(List.of()); - - minecraftTicketService.getMinecraftTicketsByCreator(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", 25); - - verify(ticketRepository).findRecentByCreator(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 25); - } - - @Test - void getMinecraftReportsForPlayerLowercasesUuidBeforeQueryingRepository() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - when(ticketRepository.findReports(any(Server.class), any(), any(), anyInt(), anyBoolean())).thenReturn(List.of()); - - minecraftTicketService.getMinecraftReportsForPlayer(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", "open", 10); - - verify(ticketRepository).findReports(eq(server), eq("open"), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), eq(10), eq(false)); - } - - @Test - void submitTicketFormCannotDisableExistingEmailAuthWhileSavingEmail() { - Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); - Ticket ticket = Ticket.builder() - .id("SUPPORT-3") - .type(TicketCategory.SUPPORT) - .status(TicketStatus.UNFINISHED) - .emailAuthEnabled(true) - .creatorName("PlayerOne") - .replies(new ArrayList<>()) - .notes(new ArrayList<>()) - .data(new HashMap<>()) - .build(); - - when(ticketRepository.findById(server, "SUPPORT-3")).thenReturn(Optional.of(ticket)); - when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) - .thenAnswer(invocation -> invocation.getArgument(1)); - - ticketService.submitTicketForm( - server, - "SUPPORT-3", - new SubmitTicketFormRequest( - "Updated subject", - "player@example.com", - java.util.Map.of("issue_type", "Bug report", "emailAuthEnabled", false), - List.of(), - "creator-1", - null - ), - false - ); - - ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); - verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); - Ticket updatedTicket = updatedTicketCaptor.getValue(); - assertTrue(updatedTicket.isEmailAuthEnabled()); - assertEquals(true, updatedTicket.getData().get("emailAuthEnabled")); - assertEquals("player@example.com", updatedTicket.getData().get("creatorEmail")); - assertEquals(false, updatedTicket.getFormData().get("emailAuthEnabled")); - } -} +package gg.modl.backend.ticket.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.settings.data.QuickResponseSettings; +import gg.modl.backend.settings.service.QuickResponseSettingsService; +import gg.modl.backend.settings.service.TicketFormSettingsService; +import gg.modl.backend.ticket.data.Ticket; +import gg.modl.backend.ticket.data.TicketCategory; +import gg.modl.backend.ticket.data.TicketPriority; +import gg.modl.backend.ticket.data.TicketReply; +import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.ticket.dto.request.CreateTicketRequest; +import gg.modl.backend.ticket.dto.request.DismissReportRequest; +import gg.modl.backend.ticket.dto.request.MinecraftClaimTicketRequest; +import gg.modl.backend.ticket.dto.request.MinecraftCreateTicketRequest; +import gg.modl.backend.ticket.dto.request.QuickResponseRequest; +import gg.modl.backend.ticket.dto.request.SubmitTicketFormRequest; +import gg.modl.backend.settings.service.WebhookSettingsService; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class TicketServiceTest { + + @Mock + private TicketMongoRepository ticketRepository; + + @Mock + private StaffMongoRepository staffRepository; + + @Mock + private QuickResponseSettingsService quickResponseSettingsService; + + @Mock + private TicketFormSettingsService ticketFormSettingsService; + + @Mock + private TicketNotificationService notificationService; + + @Mock + private TicketIdGenerator ticketIdGenerator; + + private final TicketContentService contentService = new TicketContentService(); + + @Mock + private WebhookSettingsService webhookSettingsService; + + private TicketService ticketService; + private MinecraftTicketService minecraftTicketService; + + @BeforeEach + void setUp() { + ticketService = new TicketService(ticketRepository, staffRepository, quickResponseSettingsService, ticketFormSettingsService, notificationService, ticketIdGenerator, contentService, webhookSettingsService); + minecraftTicketService = new MinecraftTicketService(ticketRepository, notificationService, ticketIdGenerator); + } + + @Test + void createMinecraftTicketMapsPluginTypeAndPersistsReply() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(ticketIdGenerator.insertWithUniqueId(any(Server.class), any(String.class), any(Ticket.class))) + .thenAnswer(invocation -> { + Ticket t = invocation.getArgument(2); + if (t.getId() == null) { + t.setId("CHAT-123456"); + } + return t; + }); + + Ticket ticket = minecraftTicketService.createMinecraftTicket(server, new MinecraftCreateTicketRequest( + "uuid-1", + "PlayerOne", + "chat", + "Chat report", + "reported bad chat", + "uuid-2", + "PlayerTwo", + List.of("hello world"), + List.of("report"), + null, + "survival", + null + )); + + assertEquals(TicketCategory.CHAT, ticket.getType()); + assertEquals(TicketPriority.NORMAL, ticket.getPriority()); + assertEquals(1, ticket.getReplies().size()); + assertEquals("reported bad chat", ticket.getReplies().get(0).getContent()); + assertEquals(1, ticket.getChatMessages().size()); + } + + @Test + void createTicketAcceptsLegacyTypeSpacingAndPriorityAliases() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(ticketIdGenerator.insertWithUniqueId(any(Server.class), any(String.class), any(Ticket.class))) + .thenAnswer(invocation -> { + Ticket t = invocation.getArgument(2); + if (t.getId() == null) { + t.setId("STAFF-123456"); + } + return t; + }); + + ticketService.createTicket(server, new CreateTicketRequest( + "staff application", + "", + "Legacy alias submit", + null, + "Applicant", + null, + null, + null, + null, + null, + null, + List.of("legacy"), + "medium", + null, + null, + null + )); + + ArgumentCaptor savedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); + verify(ticketIdGenerator).insertWithUniqueId(any(Server.class), any(String.class), savedTicketCaptor.capture()); + Ticket savedTicket = savedTicketCaptor.getValue(); + + assertEquals(TicketCategory.APPLICATION, savedTicket.getType()); + assertEquals(TicketPriority.NORMAL, savedTicket.getPriority()); + assertEquals(TicketStatus.UNFINISHED, savedTicket.getStatus()); + } + + @Test + void claimMinecraftTicketRenamesMatchingNonStaffReplies() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Ticket ticket = Ticket.builder() + .id("SUPPORT-123456") + .creatorName("Old Web User") + .replies(new ArrayList<>(List.of( + TicketReply.builder().id("1").name("Old Web User").staff(false).content("first").created(new Date()).build(), + TicketReply.builder().id("2").name("Staff").staff(true).content("staff").created(new Date()).build() + ))) + .build(); + + when(ticketRepository.findById(server, "SUPPORT-123456")).thenReturn(Optional.of(ticket)); + when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + + MinecraftTicketService.MinecraftTicketClaimResult result = minecraftTicketService.claimMinecraftTicket( + server, + "SUPPORT-123456", + new MinecraftClaimTicketRequest("uuid-new", "VerifiedPlayer") + ); + + assertEquals(MinecraftTicketService.MinecraftTicketClaimStatus.SUCCESS, result.status()); + assertEquals("uuid-new", result.ticket().getCreatorUuid()); + assertEquals("VerifiedPlayer", result.ticket().getCreatorName()); + assertEquals("VerifiedPlayer", result.ticket().getReplies().get(0).getName()); + assertEquals("Staff", result.ticket().getReplies().get(1).getName()); + assertNotNull(result.ticket().getUpdatedAt()); + + ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); + verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); + assertTrue(updatedTicketCaptor.getValue().getUpdatedAt() != null); + } + + @Test + void dismissMinecraftReportClosesTicketAndAppendsStaffReply() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Ticket ticket = Ticket.builder() + .id("REPORT-1") + .data(new HashMap<>()) + .replies(new ArrayList<>()) + .build(); + + when(ticketRepository.findById(server, "REPORT-1")).thenReturn(Optional.of(ticket)); + when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + + MinecraftTicketService.ReportOperationResult result = minecraftTicketService.dismissMinecraftReport( + server, + "REPORT-1", + new DismissReportRequest("Moderator", "Insufficient evidence") + ); + + assertEquals(MinecraftTicketService.ReportOperationStatus.SUCCESS, result.status()); + + ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); + verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); + Ticket updatedTicket = updatedTicketCaptor.getValue(); + assertEquals(TicketStatus.CLOSED, updatedTicket.getStatus()); + assertTrue(updatedTicket.isLocked()); + assertEquals(1, updatedTicket.getReplies().size()); + assertEquals("Moderator", updatedTicket.getReplies().get(0).getName()); + assertEquals("Insufficient evidence", updatedTicket.getData().get("dismissReason")); + assertEquals("Moderator", updatedTicket.getData().get("dismissedBy")); + verify(notificationService).notifyTicketReply(any(Server.class), any(Ticket.class), any(TicketReply.class)); + } + + @Test + void processQuickResponseClosesTicketThroughRepositorySave() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Ticket ticket = Ticket.builder() + .id("SUPPORT-1") + .replies(new ArrayList<>()) + .notes(new ArrayList<>()) + .build(); + QuickResponseSettings settings = QuickResponseSettings.builder().build(); + QuickResponseSettings.Action action = QuickResponseSettings.Action.builder() + .id("close") + .name("Close") + .message("Resolved") + .closeTicket(true) + .appealAction("none") + .build(); + + when(ticketRepository.findById(server, "SUPPORT-1")).thenReturn(Optional.of(ticket)); + when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + when(quickResponseSettingsService.getQuickResponseSettings(server)).thenReturn(settings); + when(quickResponseSettingsService.findAction(settings, "general", "close")).thenReturn(action); + + var result = ticketService.processQuickResponse( + server, + "SUPPORT-1", + new QuickResponseRequest("close", "general", null, null, null, null), + "Moderator" + ); + + assertTrue(result.success()); + + ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); + verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); + Ticket updatedTicket = updatedTicketCaptor.getValue(); + assertEquals(TicketStatus.CLOSED, updatedTicket.getStatus()); + assertTrue(updatedTicket.isLocked()); + assertEquals(1, updatedTicket.getReplies().size()); + assertEquals("Moderator", updatedTicket.getReplies().get(0).getName()); + verify(notificationService).notifyTicketReply(any(Server.class), any(Ticket.class), any(TicketReply.class)); + verify(notificationService).notifyTicketClosed(any(Server.class), any(Ticket.class)); + } + + @Test + void submitTicketFormPromotesUnfinishedTicketAndAddsInitialReply() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Ticket ticket = Ticket.builder() + .id("SUPPORT-2") + .type(TicketCategory.SUPPORT) + .status(TicketStatus.UNFINISHED) + .creatorName("PlayerOne") + .replies(new ArrayList<>()) + .notes(new ArrayList<>()) + .data(new HashMap<>()) + .build(); + + when(ticketRepository.findById(server, "SUPPORT-2")).thenReturn(Optional.of(ticket)); + when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + + var response = ticketService.submitTicketForm( + server, + "SUPPORT-2", + new SubmitTicketFormRequest( + "Updated subject", + "player@example.com", + java.util.Map.of("issue_type", "Bug report", "emailAuthEnabled", true), + List.of(), + "creator-1", + null + ), + true + ); + + assertNotNull(response); + + ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); + verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); + Ticket updatedTicket = updatedTicketCaptor.getValue(); + assertEquals(TicketStatus.OPEN, updatedTicket.getStatus()); + assertEquals("Updated subject", updatedTicket.getSubject()); + assertTrue(updatedTicket.isEmailAuthEnabled()); + assertEquals(true, updatedTicket.getData().get("emailAuthEnabled")); + assertEquals(true, updatedTicket.getFormData().get("emailAuthEnabled")); + assertEquals("player@example.com", updatedTicket.getData().get("creatorEmail")); + assertEquals("creator-1", updatedTicket.getData().get("creatorIdentifier")); + assertEquals(1, updatedTicket.getReplies().size()); + assertTrue(updatedTicket.getReplies().get(0).getContent().contains("Issue Type")); + } + + @Test + void getMinecraftTicketsByCreatorLowercasesUuidBeforeQueryingRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(ticketRepository.findRecentByCreator(any(Server.class), any(), anyInt())).thenReturn(List.of()); + + minecraftTicketService.getMinecraftTicketsByCreator(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", 25); + + verify(ticketRepository).findRecentByCreator(server, "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", 25); + } + + @Test + void getMinecraftReportsForPlayerLowercasesUuidBeforeQueryingRepository() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + when(ticketRepository.findReports(any(Server.class), any(), any(), anyInt(), anyBoolean())).thenReturn(List.of()); + + minecraftTicketService.getMinecraftReportsForPlayer(server, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", "open", 10); + + verify(ticketRepository).findReports(eq(server), eq("open"), eq("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"), eq(10), eq(false)); + } + + @Test + void submitTicketFormCannotDisableExistingEmailAuthWhileSavingEmail() { + Server server = new Server("server", "domain", "db", "admin@example.com", true, ServerPlan.FREE); + Ticket ticket = Ticket.builder() + .id("SUPPORT-3") + .type(TicketCategory.SUPPORT) + .status(TicketStatus.UNFINISHED) + .emailAuthEnabled(true) + .creatorName("PlayerOne") + .replies(new ArrayList<>()) + .notes(new ArrayList<>()) + .data(new HashMap<>()) + .build(); + + when(ticketRepository.findById(server, "SUPPORT-3")).thenReturn(Optional.of(ticket)); + when(ticketRepository.saveEntity(any(Server.class), any(Ticket.class))) + .thenAnswer(invocation -> invocation.getArgument(1)); + + ticketService.submitTicketForm( + server, + "SUPPORT-3", + new SubmitTicketFormRequest( + "Updated subject", + "player@example.com", + java.util.Map.of("issue_type", "Bug report", "emailAuthEnabled", false), + List.of(), + "creator-1", + null + ), + false + ); + + ArgumentCaptor updatedTicketCaptor = ArgumentCaptor.forClass(Ticket.class); + verify(ticketRepository).saveEntity(any(Server.class), updatedTicketCaptor.capture()); + Ticket updatedTicket = updatedTicketCaptor.getValue(); + assertTrue(updatedTicket.isEmailAuthEnabled()); + assertEquals(true, updatedTicket.getData().get("emailAuthEnabled")); + assertEquals("player@example.com", updatedTicket.getData().get("creatorEmail")); + assertEquals(false, updatedTicket.getFormData().get("emailAuthEnabled")); + } +}