From 071246e551bc2f51202af25507fac4d0ae93d2f3 Mon Sep 17 00:00:00 2001 From: byteful Date: Fri, 10 Jul 2026 06:41:26 -0500 Subject: [PATCH 1/6] LF + audit/analytics --- .../2026-03-12-consolidate-indexes.mongosh.js | 224 +- .../gg/modl/backend/BackendApplication.java | 60 +- src/main/java/gg/modl/backend/Constants.java | 40 +- .../controller/AdminAnalyticsController.java | 160 +- .../admin/controller/AdminAuthController.java | 222 +- .../controller/AdminMonitoringController.java | 278 +- .../controller/AdminServerController.java | 512 ++-- .../controller/AdminSystemController.java | 206 +- .../gg/modl/backend/admin/data/AdminUser.java | 74 +- .../modl/backend/admin/data/SystemConfig.java | 266 +- .../gg/modl/backend/admin/data/SystemLog.java | 100 +- .../admin/service/AdminMonitoringService.java | 594 ++--- .../admin/service/AdminSecurityService.java | 150 +- .../admin/service/AdminServerService.java | 536 ++-- .../gg/modl/backend/ai/LLMConfiguration.java | 40 +- .../java/gg/modl/backend/ai/LLMService.java | 80 +- .../backend/ai/data/AIAnalysisResult.java | 66 +- .../ai/external/GeminiLLMProvider.java | 128 +- .../modl/backend/ai/external/LLMProvider.java | 20 +- .../ai/service/AITicketAnalysisService.java | 1024 ++++---- .../controller/AnalyticsController.java | 156 +- .../response/AuditLogsAnalyticsResponse.java | 24 +- .../dto/response/OverviewResponse.java | 22 +- .../dto/response/PlayerActivityResponse.java | 30 +- .../response/PunishmentAnalyticsResponse.java | 30 +- .../dto/response/TicketAnalyticsResponse.java | 36 +- .../analytics/service/AnalyticsService.java | 31 +- .../controller/PanelAppealController.java | 194 +- .../controller/PublicAppealController.java | 256 +- .../audit/controller/AuditController.java | 470 ++-- .../gg/modl/backend/audit/data/AuditLog.java | 88 +- .../response/ActivePunishmentResponse.java | 62 +- .../dto/response/PunishmentAuditResponse.java | 34 +- .../dto/response/StaffDetailsResponse.java | 110 +- .../response/StaffPerformanceResponse.java | 30 +- .../backend/audit/service/AuditService.java | 1350 +++++----- .../backend/auth/AuthResponseMessage.java | 24 +- .../gg/modl/backend/auth/AuthService.java | 218 +- .../gg/modl/backend/auth/WebAuthnService.java | 808 +++--- .../auth/controller/PanelAuthController.java | 656 ++--- .../auth/controller/WebAuthnController.java | 372 +-- .../backend/auth/data/WebAuthnChallenge.java | 60 +- .../backend/auth/data/WebAuthnCredential.java | 90 +- .../backend/auth/session/AuthSessionData.java | 74 +- .../billing/config/StripeConfiguration.java | 92 +- .../controller/PanelBillingController.java | 254 +- .../billing/dto/response/CancelResponse.java | 18 +- .../dto/response/CheckoutSessionResponse.java | 12 +- .../dto/response/PortalSessionResponse.java | 10 +- .../dto/response/ResubscribeResponse.java | 30 +- .../UsageBillingSettingsResponse.java | 14 +- .../billing/dto/response/UsageResponse.java | 48 +- .../billing/service/BillingService.java | 476 ++-- .../billing/service/StripeService.java | 292 +-- .../billing/service/StripeWebhookService.java | 546 ++-- .../service/SubscriptionExpiryService.java | 90 +- .../billing/service/UsageTrackingService.java | 286 +-- .../config/CloudflareConfiguration.java | 42 +- .../cloudflare/external/CloudflareClient.java | 402 +-- .../controller/DashboardController.java | 166 +- .../dto/response/ActivityItemResponse.java | 42 +- .../response/DashboardMetricsResponse.java | 26 +- .../response/RecentPunishmentResponse.java | 30 +- .../dto/response/RecentTicketResponse.java | 30 +- .../dashboard/service/DashboardService.java | 776 +++--- .../database/MongoIndexBootstrapService.java | 1246 ++++----- .../repository/AnalyticsMongoRepository.java | 10 +- .../repository/InvitationMongoRepository.java | 108 +- .../repository/MigrationMongoRepository.java | 218 +- .../repository/PlayerMongoRepository.java | 526 ++-- .../repository/ServerMongoRepository.java | 2254 ++++++++--------- .../repository/SettingsMongoRepository.java | 192 +- .../repository/StaffMongoRepository.java | 488 ++-- .../repository/StaffRoleMongoRepository.java | 284 +-- .../repository/TicketMongoRepository.java | 1184 ++++----- .../backend/email/EmailConfiguration.java | 34 +- .../modl/backend/email/EmailHTMLTemplate.java | 688 ++--- .../gg/modl/backend/email/EmailService.java | 46 +- .../PanelHomepageCardController.java | 186 +- .../PublicHomepageCardController.java | 72 +- .../dto/response/HomepageCardResponse.java | 154 +- .../homepage/service/HomepageCardService.java | 328 +-- .../infrastructure/config/AsyncConfig.java | 86 +- .../config/DevelopmentModeGuard.java | 90 +- .../config/ModlCorsProperties.java | 32 +- .../config/ModlDevProperties.java | 34 +- .../infrastructure/config/ModlProperties.java | 36 +- .../cors/DynamicCorsConfigurationSource.java | 308 +-- .../exception/BaseApplicationException.java | 38 +- .../exception/ConflictException.java | 26 +- .../exception/ErrorResponseDTO.java | 14 +- .../exception/ExternalServiceException.java | 26 +- .../exception/ForbiddenException.java | 26 +- .../exception/ResourceNotFoundException.java | 26 +- .../exception/UnauthorizedException.java | 26 +- .../exception/ValidationException.java | 26 +- .../filter/AdminAuthFilter.java | 118 +- .../infrastructure/filter/FilterConfig.java | 90 +- .../filter/PanelPermissionFilter.java | 466 ++-- .../filter/V1SecurityConfig.java | 108 +- .../infrastructure/rest/HealthController.java | 52 +- .../infrastructure/rest/RESTSecurityRole.java | 16 +- .../infrastructure/rest/RequestAttribute.java | 4 +- .../infrastructure/rest/RequestHeader.java | 4 +- .../infrastructure/rest/RequestUtil.java | 418 +-- .../turnstile/TurnstileConfiguration.java | 40 +- .../turnstile/TurnstileService.java | 274 +- .../infrastructure/util/ByteFormatUtil.java | 66 +- .../infrastructure/util/CookieUtil.java | 144 +- .../infrastructure/util/DateRangeUtil.java | 106 +- .../infrastructure/util/IdGenerator.java | 56 +- .../infrastructure/util/PaginationHelper.java | 76 +- .../infrastructure/util/SafeConvertUtil.java | 24 +- .../validation/RegExpConstants.java | 16 +- .../PanelKnowledgebaseController.java | 368 +-- .../PublicKnowledgebaseController.java | 152 +- .../data/KnowledgebaseCategory.java | 78 +- .../service/KnowledgebaseArticleService.java | 220 +- .../gg/modl/backend/log/data/SystemLog.java | 72 +- .../log/dto/response/SystemLogResponse.java | 22 +- .../modl/backend/log/service/LogService.java | 120 +- .../MinecraftMigrationController.java | 190 +- .../controller/PanelMigrationController.java | 124 +- .../migration/data/MigrationStatus.java | 124 +- .../migration/dto/MigrationPlayerData.java | 92 +- .../migration/dto/UpdateProgressRequest.java | 26 +- .../migration/service/MigrationProcessor.java | 1698 ++++++------- .../migration/service/MigrationService.java | 472 ++-- .../validation/MigrationValidator.java | 300 +-- .../backend/player/PlayerResponseMessage.java | 12 +- .../controller/MinecraftPlayerController.java | 582 ++--- .../MinecraftPunishmentController.java | 922 +++---- .../controller/PanelPlayerController.java | 642 ++--- .../gg/modl/backend/player/data/IPEntry.java | 78 +- .../modl/backend/player/data/NoteEntry.java | 48 +- .../backend/player/data/UsernameEntry.java | 12 +- .../player/data/punishment/Punishment.java | 146 +- .../data/punishment/PunishmentEvidence.java | 36 +- .../punishment/PunishmentModification.java | 40 +- .../data/punishment/PunishmentNote.java | 30 +- .../dto/request/CreatePlayerRequest.java | 22 +- .../dto/response/LinkedAccountResponse.java | 26 +- .../dto/response/PlayerDetailResponse.java | 52 +- .../dto/response/PlayerSearchResult.java | 24 +- .../dto/response/PunishmentSearchResult.java | 24 +- .../player/service/MojangApiService.java | 202 +- .../service/PunishmentEvidenceService.java | 338 +-- .../player/service/PunishmentMapper.java | 396 +-- .../service/PunishmentMutationService.java | 748 +++--- .../PublicPunishmentController.java | 76 +- .../role/controller/PanelRoleController.java | 322 +-- .../gg/modl/backend/role/data/Permission.java | 26 +- .../gg/modl/backend/role/data/StaffRole.java | 96 +- .../role/dto/response/RoleResponse.java | 34 +- .../role/service/PermissionService.java | 544 ++-- .../backend/role/service/RoleService.java | 788 +++--- .../gg/modl/backend/server/ServerField.java | 4 +- .../backend/server/ServerResponseMessage.java | 20 +- .../gg/modl/backend/server/ServerService.java | 622 ++--- .../controller/PanelServerController.java | 62 +- .../controller/PublicServerController.java | 72 +- .../gg/modl/backend/server/data/Server.java | 410 +-- .../server/data/ServerBillingUpdate.java | 58 +- .../service/ServerProvisioningService.java | 1190 ++++----- .../MinecraftPunishmentTypeController.java | 66 +- .../PanelDomainSettingsController.java | 196 +- .../PanelPunishmentTypeController.java | 274 +- .../controller/PublicSettingsController.java | 140 +- .../settings/data/AIModerationSettings.java | 82 +- .../backend/settings/data/AppealForm.java | 42 +- .../settings/data/AppealFormField.java | 44 +- .../settings/data/AppealFormSection.java | 38 +- .../settings/data/DefaultPunishmentTypes.java | 1082 ++++---- .../backend/settings/data/DomainSettings.java | 54 +- .../backend/settings/data/DurationDetail.java | 102 +- .../settings/data/GeneralSettings.java | 4 +- .../gg/modl/backend/settings/data/Label.java | 34 +- .../data/OffenderThresholdSettings.java | 268 +- .../settings/data/OffenseLevelDurations.java | 36 +- .../settings/data/PunishmentDurations.java | 36 +- .../settings/data/PunishmentPoints.java | 42 +- .../backend/settings/data/PunishmentType.java | 298 +-- .../settings/data/QuickResponseSettings.java | 130 +- .../settings/data/TicketFormSettings.java | 156 +- .../settings/data/WebhookSettings.java | 140 +- .../service/GeneralSettingsService.java | 238 +- .../settings/service/IconUploadService.java | 164 +- .../OffenderThresholdSettingsService.java | 248 +- .../service/SettingsConflictException.java | 28 +- .../service/SettingsDocumentService.java | 198 +- .../service/TicketFormSettingsService.java | 336 +-- .../controller/MinecraftStaffController.java | 222 +- .../controller/PanelStaffController.java | 466 ++-- .../controller/PublicStaffController.java | 198 +- .../gg/modl/backend/staff/data/Staff.java | 308 +-- .../dto/response/AvailablePlayerResponse.java | 14 +- .../dto/response/InviteResultResponse.java | 30 +- .../MinecraftStaffPermissionsResponse.java | 28 +- .../MinecraftStaffSummaryResponse.java | 42 +- .../staff/dto/response/StaffResponse.java | 30 +- .../staff/service/InvitationService.java | 594 ++--- .../controller/PanelStorageController.java | 222 +- .../dto/response/PresignUploadResponse.java | 24 +- .../dto/response/StorageFileResponse.java | 26 +- .../dto/response/StorageQuotaResponse.java | 50 +- .../storage/dto/response/UploadResponse.java | 20 +- .../service/EvidenceUploadService.java | 590 ++--- .../storage/service/MediaAccessService.java | 182 +- .../service/MediaValidationService.java | 336 +-- .../MinecraftReportsController.java | 264 +- .../MinecraftTicketsController.java | 392 +-- .../controller/PanelTicketController.java | 516 ++-- .../controller/PublicTicketController.java | 402 +-- .../TicketSubscriptionController.java | 278 +- .../modl/backend/ticket/data/TicketNote.java | 36 +- .../backend/ticket/data/TicketPriority.java | 100 +- .../modl/backend/ticket/data/TicketReply.java | 54 +- .../response/PaginatedTicketsResponse.java | 52 +- .../dto/response/QuickResponseResult.java | 22 +- .../response/SubscriptionUpdateResponse.java | 32 +- .../dto/response/TicketListItemResponse.java | 46 +- .../response/TicketSubscriptionResponse.java | 20 +- .../TicketEmailVerificationService.java | 262 +- .../ticket/service/TicketReplyService.java | 254 +- .../ticket/service/TicketSearchService.java | 286 +-- .../service/GlobalSystemServiceTest.java | 258 +- .../SessionAuthenticationFilterTest.java | 206 +- .../billing/service/BillingServiceTest.java | 124 +- .../SubscriptionExpiryServiceTest.java | 130 +- .../service/UsageTrackingServiceTest.java | 110 +- .../service/DashboardServiceTest.java | 392 +-- .../minecraft/MinecraftDashboardApiTest.java | 66 +- .../minecraft/MinecraftMigrationApiTest.java | 78 +- .../MinecraftNotificationApiTest.java | 64 +- .../minecraft/MinecraftPlayerApiTest.java | 390 +-- .../minecraft/MinecraftPunishmentApiTest.java | 826 +++--- .../MinecraftPunishmentTypeApiTest.java | 58 +- .../minecraft/MinecraftReportApiTest.java | 188 +- .../minecraft/MinecraftRoleApiTest.java | 168 +- .../minecraft/MinecraftStaffApiTest.java | 116 +- .../minecraft/MinecraftStatWipeApiTest.java | 378 +-- .../minecraft/MinecraftSyncApiTest.java | 82 +- .../minecraft/MinecraftTicketApiTest.java | 268 +- .../backend/panel/PanelAnalyticsApiTest.java | 120 +- .../backend/panel/PanelAppealApiTest.java | 160 +- .../modl/backend/panel/PanelAuthApiTest.java | 136 +- .../backend/panel/PanelBillingApiTest.java | 114 +- .../backend/panel/PanelDashboardApiTest.java | 92 +- .../panel/PanelHomepageCardApiTest.java | 226 +- .../panel/PanelKnowledgebaseApiTest.java | 608 ++--- .../modl/backend/panel/PanelLogApiTest.java | 52 +- .../modl/backend/panel/PanelMediaApiTest.java | 118 +- .../backend/panel/PanelMigrationApiTest.java | 78 +- .../backend/panel/PanelPlayerApiTest.java | 608 ++--- .../modl/backend/panel/PanelRoleApiTest.java | 288 +-- .../backend/panel/PanelSettingsApiTest.java | 250 +- .../modl/backend/panel/PanelStaffApiTest.java | 314 +-- .../backend/panel/PanelStorageApiTest.java | 118 +- .../backend/panel/PanelTicketApiTest.java | 572 ++--- .../panel/PanelTicketSubscriptionApiTest.java | 106 +- .../service/MinecraftPlayerServiceTest.java | 370 +-- .../service/MinecraftSyncServiceTest.java | 270 +- .../PlayerStatusCalculatorStatWipeTest.java | 484 ++-- .../player/service/PunishmentServiceTest.java | 826 +++--- .../public_api/EvidenceUploadApiTest.java | 220 +- .../public_api/PublicAppealApiTest.java | 216 +- .../public_api/PublicHomepageCardApiTest.java | 52 +- .../PublicKnowledgebaseApiTest.java | 122 +- .../public_api/PublicMediaApiTest.java | 104 +- .../public_api/PublicPlayerApiTest.java | 62 +- .../public_api/PublicPunishmentApiTest.java | 108 +- .../public_api/PublicServerApiTest.java | 40 +- .../public_api/PublicSettingsApiTest.java | 60 +- .../public_api/PublicTicketApiTest.java | 430 ++-- .../PublicPunishmentControllerTest.java | 122 +- .../service/EvidenceUploadServiceTest.java | 394 +-- .../gg/modl/backend/support/ApiClient.java | 340 +-- .../backend/support/DatabaseCleanupTest.java | 128 +- .../modl/backend/support/DebugDataTest.java | 160 +- .../gg/modl/backend/support/JsonHelper.java | 98 +- .../backend/support/TestDataProvider.java | 540 ++-- .../gg/modl/backend/support/TestDatabase.java | 332 +-- .../ticket/service/TicketServiceTest.java | 742 +++--- 283 files changed, 31139 insertions(+), 31156 deletions(-) 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")); + } +} From e6540aa0be0e3cd3384102b8cd4f51655cbe3930 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 11 Jul 2026 02:51:37 -0500 Subject: [PATCH 2/6] fixes. --- .../gg/modl/backend/auth/controller/PanelAuthController.java | 4 ++-- src/main/java/gg/modl/backend/staff/data/Staff.java | 4 +++- src/main/java/gg/modl/backend/staff/service/StaffService.java | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) 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 5100fdd..019cee7 100644 --- a/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java +++ b/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java @@ -152,7 +152,7 @@ public ResponseEntity updateProfile( if (isSuperAdmin) { String resolvedUsername = username != null ? username : "Admin"; String resolvedLanguage = language != null ? language : "en"; - String resolvedDateFormat = dateFormat != null ? dateFormat : "MM/DD/YYYY"; + String resolvedDateFormat = dateFormat != null ? dateFormat : Staff.DEFAULT_DATE_FORMAT; return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( null, email, resolvedUsername, "Super Admin", resolvedUsername, resolvedLanguage, resolvedDateFormat)); } @@ -228,7 +228,7 @@ public ResponseEntity getCurrentUser(HttpServletRequest request) { // 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.ok(PanelAuthProtoMapper.toProfileResponse(null, email, "Admin", "Super Admin", "Admin", "en", Staff.DEFAULT_DATE_FORMAT)); } return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); 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 f1cbb74..0567fb2 100644 --- a/src/main/java/gg/modl/backend/staff/data/Staff.java +++ b/src/main/java/gg/modl/backend/staff/data/Staff.java @@ -30,6 +30,8 @@ @MongoFieldAlias(name = "SUBSCRIBED_TICKET_POS_LAST_READ_AT", path = "subscribedTickets.$.lastReadAt") }) public class Staff { + public static final String DEFAULT_DATE_FORMAT = "MMM D, YYYY"; + @Id private String id; @@ -55,7 +57,7 @@ public class Staff { @Field("dateFormat") @Builder.Default - private String dateFormat = "MM/DD/YYYY"; + private String dateFormat = DEFAULT_DATE_FORMAT; @Field("subscribedTickets") @Builder.Default diff --git a/src/main/java/gg/modl/backend/staff/service/StaffService.java b/src/main/java/gg/modl/backend/staff/service/StaffService.java index 99f38fa..c820543 100644 --- a/src/main/java/gg/modl/backend/staff/service/StaffService.java +++ b/src/main/java/gg/modl/backend/staff/service/StaffService.java @@ -526,7 +526,7 @@ public Optional updateOrCreateProfileUsername(Server server, String email hasChanges = true; } - if (newDateFormat != null && List.of("MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD").contains(newDateFormat)) { + if (newDateFormat != null && List.of(Staff.DEFAULT_DATE_FORMAT, "MM/DD/YYYY", "DD/MM/YYYY", "YYYY-MM-DD").contains(newDateFormat)) { staff.setDateFormat(newDateFormat); hasChanges = true; } From 19217aff9f6ebbcd452a0fe7d2d5dc509d9a21e1 Mon Sep 17 00:00:00 2001 From: byteful Date: Sat, 11 Jul 2026 23:32:28 -0500 Subject: [PATCH 3/6] trim --- .../mongo/codegen/MongoFieldsProcessor.java | 2 +- .../controller/AdminAnalyticsController.java | 18 +- .../controller/AdminAnalyticsProtoMapper.java | 211 ++- .../admin/controller/AdminAuthController.java | 3 +- .../controller/AdminMonitoringController.java | 13 +- .../AdminMonitoringProtoMapper.java | 158 ++- .../controller/AdminSecurityProtoMapper.java | 54 +- .../controller/AdminServerController.java | 3 +- .../controller/AdminSystemController.java | 13 +- .../controller/AdminSystemProtoMapper.java | 27 +- .../gg/modl/backend/admin/data/AdminUser.java | 3 +- .../modl/backend/admin/data/SystemConfig.java | 3 +- .../modl/backend/admin/data/SystemPrompt.java | 3 +- .../dto/response/AdminAnalyticsActivity.java | 16 + .../dto/response/AdminAnalyticsDashboard.java | 60 + .../dto/response/AdminAnalyticsExport.java | 10 + .../response/AdminAnalyticsHistorical.java | 10 + .../dto/response/AdminAnalyticsUsage.java | 10 + .../dto/response/AdminHistoricalPoint.java | 7 + .../dto/response/AdminMaintenanceStatus.java | 7 + .../response/AdminMonitoringDashboard.java | 50 + .../dto/response/AdminMonitoringHealth.java | 31 + .../dto/response/AdminMonitoringLogs.java | 20 + .../dto/response/AdminMonitoringSources.java | 9 + .../admin/dto/response/AdminNameCount.java | 7 + .../admin/dto/response/AdminPagination.java | 9 + .../dto/response/AdminRateLimitStatus.java | 11 + .../dto/response/AdminRegistrationPoint.java | 7 + .../dto/response/AdminSecurityEvents.java | 10 + .../dto/response/AdminSecuritySummary.java | 16 + .../admin/service/AdminAnalyticsService.java | 227 ++-- .../admin/service/AdminAuthService.java | 3 +- .../admin/service/AdminMonitoringService.java | 188 ++- .../admin/service/AdminSecurityService.java | 39 +- .../admin/service/AdminServerService.java | 126 +- .../admin/service/GlobalSystemService.java | 32 +- .../ai/external/GeminiLLMProvider.java | 7 +- .../ai/service/AITicketAnalysisService.java | 185 +-- .../ai/service/AiAnalysisResponseParser.java | 89 ++ .../service/ChatModerationPromptBuilder.java | 101 ++ .../AdminSystemAlertController.java | 26 +- .../PanelSystemAlertController.java | 3 + .../controller/AnalyticsController.java | 2 + .../analytics/data/MetricSnapshot.java | 2 +- .../analytics/service/AnalyticsService.java | 113 +- .../service/MetricSnapshotService.java | 12 +- .../controller/PanelAppealController.java | 7 +- .../controller/PanelAppealProtoMapper.java | 30 +- .../controller/PublicAppealController.java | 13 +- .../backend/appeal/service/AppealService.java | 22 +- .../audit/controller/AuditController.java | 45 +- .../service/AdminDatabaseBrowserService.java | 97 ++ .../audit/service/AuditProjectionKeys.java | 17 + .../backend/audit/service/AuditService.java | 211 +-- .../service/PunishmentDocumentMapper.java | 91 ++ .../service/StaffPerformanceService.java | 89 +- .../backend/auth/AuthResponseMessage.java | 4 - .../modl/backend/auth/EmailChangeService.java | 8 +- .../auth/controller/PanelAuthController.java | 68 +- .../auth/controller/WebAuthnController.java | 36 +- .../backend/auth/session/SessionPublicId.java | 16 +- .../backend/auth/session/SessionService.java | 19 - .../beta/AdminBetaTesterController.java | 8 +- .../backend/beta/AdminBetaTesterService.java | 28 +- .../modl/backend/beta/BetaAuditService.java | 13 +- .../modl/backend/beta/BetaResetService.java | 10 +- .../gg/modl/backend/beta/data/BetaAudit.java | 6 + .../billing/config/StripeConfiguration.java | 12 +- .../controller/PanelBillingController.java | 4 + .../billing/service/BillingService.java | 20 +- .../billing/service/StripeService.java | 18 +- .../billing/service/StripeWebhookService.java | 238 ++-- .../service/SubscriptionExpiryService.java | 6 +- .../billing/service/UsageTrackingService.java | 20 +- .../cloudflare/external/CloudflareClient.java | 30 +- .../controller/DashboardController.java | 2 + .../dashboard/service/DashboardService.java | 6 +- .../modl/backend/database/CollectionName.java | 14 +- .../DynamicMongoTemplateProvider.java | 10 +- ...Service.java => MongoIndexReconciler.java} | 342 +++-- .../database/TenantMigrationService.java | 11 +- .../database/TenantSchemaBootstrapper.java | 110 ++ .../mongo/AbstractServerMongoRepository.java | 28 + .../mongo/AbstractTenantMongoRepository.java | 4 - .../AdminDatabaseBrowserRepository.java | 28 + .../repository/AdminUserMongoRepository.java | 4 +- .../repository/AnalyticsMongoRepository.java | 226 +++- .../mongo/repository/AuditLogRepository.java | 66 + .../GlobalMongoAdminRepository.java | 4 +- .../HomepageCardMongoRepository.java | 13 +- .../KnowledgebaseArticleMongoRepository.java | 19 +- .../KnowledgebaseCategoryMongoRepository.java | 14 +- .../repository/MigrationMongoRepository.java | 4 +- .../repository/PlayerMongoRepository.java | 41 +- .../repository/PunishmentMongoRepository.java | 117 +- .../SecurityEventMongoRepository.java | 18 +- .../repository/ServerActivityRepository.java | 36 + .../repository/ServerAdminRepository.java | 164 +++ .../ServerBetaTesterRepository.java | 71 ++ .../ServerCredentialRepository.java | 43 + .../ServerCustomDomainRepository.java | 48 + ...ServerInstanceSnapshotMongoRepository.java | 4 +- .../repository/ServerLogMongoRepository.java | 8 +- .../repository/ServerLookupRepository.java | 90 ++ .../repository/ServerMetricsRepository.java | 259 ++++ .../repository/ServerMongoRepository.java | 1127 ----------------- .../ServerProvisioningRepository.java | 107 ++ .../ServerRegistrationCleanupRepository.java | 167 +++ .../ServerSettingsTimestampRepository.java | 38 + .../repository/ServerUsageRepository.java | 191 +++ .../repository/SettingsMongoRepository.java | 16 +- ... => StaffActivityAnalyticsRepository.java} | 209 +-- .../repository/StaffMongoRepository.java | 14 +- .../repository/StaffRoleMongoRepository.java | 21 +- .../StorageFileMongoRepository.java | 15 +- .../StripeWebhookEventMongoRepository.java | 8 +- .../SystemConfigMongoRepository.java | 4 +- .../repository/SystemLogMongoRepository.java | 27 +- .../SystemPromptMongoRepository.java | 4 +- .../repository/TicketMongoRepository.java | 6 +- .../repository/TrainingSegmentRepository.java | 6 +- .../gg/modl/backend/dev/DevTenantSeeder.java | 8 +- .../controller/HomepageProtoMapper.java | 77 +- .../PanelHomepageCardController.java | 2 + .../PublicHomepageCardController.java | 11 +- .../dto/response/HomepageCardResponse.java | 77 -- .../homepage/service/HomepageCardService.java | 42 +- .../authorization/AppealReplyPolicy.java | 21 + .../authorization/PanelAccessPolicy.java | 7 + .../PanelAccessPolicyResolver.java | 98 ++ .../authorization/PanelAccessRequest.java | 7 + .../authorization/PanelAccessRule.java | 8 + .../PanelAuthorizationBootstrapValidator.java | 52 + .../PanelPrincipalPermissions.java | 15 + .../authorization/PermitAllPolicy.java | 15 + .../authorization/PlayerAccessPolicy.java | 24 + .../ReadWritePermissionPolicy.java | 13 + .../RequiresPanelPermission.java | 18 + .../config/AuthCodeHashSecretGuard.java | 13 +- .../config/DevelopmentModeGuard.java | 4 +- .../config/ProfileEnvironment.java | 15 + .../cors/DynamicCorsConfigurationSource.java | 26 +- .../exception/CustomErrorController.java | 23 +- .../exception/GlobalExceptionHandler.java | 110 +- .../filter/AdminAuthFilter.java | 11 +- .../infrastructure/filter/ApiKeyFilter.java | 9 +- .../infrastructure/filter/FilterConfig.java | 6 +- .../filter/OriginCsrfFilter.java | 22 +- .../filter/PanelPermissionFilter.java | 181 +-- .../filter/ServerHeaderFilter.java | 6 +- .../filter/SessionAuthenticationFilter.java | 8 +- .../filter/V1SecurityConfig.java | 2 - .../onetimecode/OneTimeCodeCodec.java | 4 +- .../origin/OriginPolicyFactory.java | 31 + .../ProtoBinaryHttpMessageConverter.java | 10 +- .../proto/ProtoJsonHttpMessageConverter.java | 10 +- .../proto/ProtoMapperSupport.java | 68 +- .../proto/ProtoMessageConverterConfig.java | 19 +- .../infrastructure/proto/ProtoMessages.java | 16 + .../proto/ProtoValidationException.java | 2 +- .../proto/ProtobufErrorResponseWriter.java | 7 +- .../infrastructure/ratelimit/BucketPool.java | 34 + .../ratelimit/RateLimitConfig.java | 72 +- .../infrastructure/rest/MetricsConfig.java | 7 - .../infrastructure/rest/RESTMappingV1.java | 29 +- .../infrastructure/rest/RESTMappingV2.java | 3 + .../infrastructure/rest/RESTSecurityRole.java | 3 + .../infrastructure/rest/RequestAttribute.java | 3 + .../infrastructure/rest/RequestHeader.java | 3 + .../infrastructure/rest/RequestUtil.java | 37 +- .../infrastructure/rest/RouteGroups.java | 81 ++ .../scheduling/KeysetDrainer.java | 40 + .../infrastructure/util/CookieUtil.java | 21 +- .../infrastructure/util/DigestUtils.java | 21 + .../util/HostExtractionUtil.java | 6 +- .../infrastructure/util/IdGenerator.java | 6 +- .../infrastructure/util/MongoKeyUtils.java | 8 - .../infrastructure/util/SafeConvertUtil.java | 12 - .../infrastructure/util/UuidUtils.java | 33 + .../validation/BeanValidationRunner.java | 22 + .../validation/RegExpConstants.java | 5 +- .../controller/KnowledgebaseProtoMapper.java | 10 +- .../PanelKnowledgebaseController.java | 5 +- .../log/controller/PanelLogController.java | 2 + .../data/{SystemLog.java => ServerLog.java} | 2 +- .../modl/backend/log/service/LogService.java | 6 +- .../log/service/PanelActionAuditor.java | 6 +- .../controller/MigrationProtoMapper.java | 26 +- .../MinecraftMigrationController.java | 86 +- .../controller/PanelMigrationController.java | 12 +- .../migration/dto/MigrationPlayerData.java | 46 - .../migration/service/MigrationMessages.java | 13 + .../migration/service/MigrationProcessor.java | 519 +------- .../service/MigrationRecordMapper.java | 503 ++++++++ .../migration/service/MigrationService.java | 60 +- .../service/MigrationUploadService.java | 66 + .../validation/MigrationValidator.java | 126 +- .../gg/modl/backend/player/PlayerService.java | 62 +- .../MinecraftChatLogController.java | 2 - .../MinecraftNotificationController.java | 9 +- .../MinecraftNotificationV3Controller.java | 5 +- .../controller/MinecraftPlayerController.java | 180 ++- .../MinecraftPlayerProtoMapper.java | 279 ++-- .../MinecraftPlayerV3Controller.java | 147 ++- .../MinecraftPunishmentController.java | 20 +- .../MinecraftPunishmentProtoMapper.java | 90 +- .../MinecraftPunishmentV3Controller.java | 18 +- .../MinecraftStartupController.java | 11 +- .../controller/MinecraftSyncController.java | 32 +- .../controller/MinecraftSyncProtoMapper.java | 24 +- .../controller/MinecraftSyncV2Controller.java | 21 +- .../controller/MinecraftSyncV3Controller.java | 4 +- .../controller/PanelPlayerController.java | 42 +- .../controller/PanelPlayerProtoMapper.java | 25 +- .../controller/PublicPlayerController.java | 19 +- .../PunishmentPreviewProtoMapper.java | 29 +- .../player/controller/SyncRequestMapper.java | 53 + .../gg/modl/backend/player/data/Player.java | 13 + .../backend/player/data/PlayerDataKeys.java | 21 + .../backend/player/data/PlayerDataView.java | 134 ++ .../data/punishment/EnforcementCategory.java | 2 +- .../player/data/punishment/Punishment.java | 30 + .../data/punishment/PunishmentData.java | 88 +- .../data/punishment/PunishmentDataView.java | 190 +++ .../PunishmentModificationType.java | 13 +- .../punishment/PunishmentToggleOption.java | 44 + .../player/dto/request/AddIpRequest.java | 9 - .../dto/request/CreatePlayerRequest.java | 11 - .../dto/request/CreateUploadTokenRequest.java | 2 +- .../MinecraftCreatePunishmentRequest.java | 26 + .../player/dto/request/StartupRequest.java | 10 + .../dto/response/AcknowledgeResult.java | 4 + .../dto/response/AppealEligibility.java | 10 + .../player/dto/response/AppealInfoView.java | 18 + .../player/dto/response/CreateNoteResult.java | 10 + .../dto/response/LinkedAccountsResult.java | 18 + .../player/dto/response/LinkedBanView.java | 9 + .../dto/response/OnlinePlayersResult.java | 7 + .../dto/response/PaginatedNotesResult.java | 18 + .../response/PaginatedPunishmentsResult.java | 18 + .../player/dto/response/PardonResult.java | 10 + .../dto/response/PlayerFetchResult.java | 16 + .../dto/response/PlayerLoginResult.java | 12 + .../dto/response/PlayerLookupResult.java | 12 + .../dto/response/PlayerProfileResult.java | 12 + .../dto/response/PlayerReportsResult.java | 7 + .../response/PunishmentPreviewResponse.java | 16 +- .../player/dto/response/PunishmentView.java | 27 + .../dto/response/SimpleActionResult.java | 4 + .../dto/response/SimplePunishmentView.java | 27 + .../player/dto/response/SyncDataView.java | 20 + .../dto/response/SyncPunishmentEntry.java | 8 + .../player/dto/response/SyncResult.java | 7 + .../player/service/AccountLinkingService.java | 26 +- .../player/service/DuplicatePlayerMerger.java | 2 +- .../player/service/IssuerNameResolver.java | 37 +- .../service/MinecraftChatLogService.java | 14 +- .../service/MinecraftPlayerService.java | 295 ++--- .../service/MinecraftStartupService.java | 26 +- .../player/service/MinecraftSyncService.java | 396 +++--- .../player/service/MojangApiService.java | 28 +- .../player/service/PlayerLookupService.java | 201 +-- .../service/PlayerStatusCalculator.java | 76 +- .../service/PunishmentDurationCalculator.java | 5 +- .../service/PunishmentEvidenceService.java | 18 +- .../service/PunishmentLifecycleService.java | 347 +++-- .../player/service/PunishmentMapper.java | 147 ++- .../service/PunishmentMutationService.java | 87 +- .../service/PunishmentQueryService.java | 154 +-- .../service/PunishmentRealtimePublisher.java | 40 +- .../player/service/SyncProtoFactory.java | 100 +- .../player/service/SyncStaffEventService.java | 57 +- .../PublicPunishmentController.java | 13 +- .../PublicPunishmentProtoMapper.java | 41 +- .../auth/RealtimeTopicAuthorizer.java | 6 +- .../publish/RealtimeEventPublisher.java | 10 +- ...ltimeUnauthenticatedConnectionLimiter.java | 6 +- .../state/RealtimeConnectionRegistry.java | 9 - .../state/RealtimeConnectionState.java | 24 +- .../realtime/transport/RealtimeCodec.java | 14 +- .../transport/RealtimeWebSocketHandler.java | 4 +- .../PublicRegistrationController.java | 22 +- .../registration/RegistrationService.java | 65 +- .../cleanup/RegistrationCleanupService.java | 35 +- .../replay/config/ReplayExecutorConfig.java | 31 + .../controller/MinecraftReplayController.java | 2 - .../controller/PanelReplayController.java | 2 + .../replay/controller/ReplayProtoMapper.java | 6 +- .../replay/data/TrainingSegmentDocument.java | 6 +- .../replay/dto/PlayerReplayResponse.java | 12 +- .../service/LegacyReplayCleanupService.java | 36 +- .../service/PlayerReplayListingService.java | 123 ++ .../replay/service/ReplayDeletionService.java | 3 +- .../backend/replay/service/ReplayService.java | 162 +-- .../replay/service/TrainingDataService.java | 3 +- .../replay/util/ReplayReferenceUtil.java | 16 +- .../replaylite/data/ReplayLiteLabelRange.java | 2 +- .../repository/ReplayLiteMongoRepository.java | 4 - .../service/ReplayLiteAbuseGuard.java | 61 +- .../service/ReplayLiteCleanupService.java | 25 +- .../replaylite/service/ReplayLiteService.java | 74 +- .../storage/ReplayLiteStorageService.java | 32 +- .../controller/MinecraftRolesProtoMapper.java | 46 +- .../MinecraftRolesV3Controller.java | 1 - .../role/controller/PanelRoleController.java | 19 +- .../role/controller/PanelRoleProtoMapper.java | 35 +- .../role/service/PermissionService.java | 99 +- .../role/service/RoleAuthorization.java | 2 +- .../backend/role/service/RoleService.java | 40 +- .../gg/modl/backend/server/ServerField.java | 1 - .../backend/server/ServerResponseMessage.java | 10 - .../gg/modl/backend/server/ServerService.java | 77 +- .../controller/PanelServerController.java | 11 +- .../gg/modl/backend/server/data/Server.java | 29 +- .../server/service/PanelDomainResolver.java | 21 + .../server/service/ServerMutationHelper.java | 6 +- .../service/ServerProvisioningService.java | 49 +- .../service/ServerTimestampService.java | 47 +- .../MinecraftPunishmentTypeProtoMapper.java | 11 +- .../PanelAiSuggestionController.java | 54 + .../controller/PanelApiKeyController.java | 95 ++ .../PanelDomainSettingsController.java | 16 +- .../PanelPunishmentTypeController.java | 21 +- .../controller/PanelSettingsController.java | 154 +-- .../controller/PanelSettingsProtoMapper.java | 8 +- .../SettingsInvalidationPublisher.java | 17 + .../backend/settings/data/DomainSettings.java | 4 +- .../backend/settings/data/DurationDetail.java | 6 +- .../gg/modl/backend/settings/data/Label.java | 8 +- .../data/OffenderThresholdSettings.java | 49 - .../settings/data/PunishmentCategory.java | 30 + .../backend/settings/data/PunishmentType.java | 16 +- .../dto/request/PunishmentTypeRequest.java | 72 -- .../request/UpdateWebhookSettingsRequest.java | 100 -- .../service/AIModerationSettingsService.java | 27 +- .../service/ApiKeySettingsService.java | 87 +- .../service/DomainSettingsService.java | 101 +- .../service/GeneralSettingsService.java | 33 +- .../OffenderThresholdSettingsService.java | 45 +- .../service/PunishmentTypeService.java | 13 +- .../service/QuickResponseSettingsService.java | 36 +- .../ReplayRetentionSettingsService.java | 28 +- .../service/SettingsDocumentService.java | 4 + .../service/SettingsRepositoryAccess.java | 17 - .../service/TicketFormSettingsService.java | 52 +- .../service/TicketLabelSettingsService.java | 30 +- .../service/VersionedSettingsSupport.java | 58 + .../service/WebhookSettingsService.java | 35 +- .../controller/MinecraftStaffController.java | 4 +- .../controller/MinecraftStaffProtoMapper.java | 67 +- .../MinecraftStaffV3Controller.java | 4 +- .../controller/PanelStaffController.java | 23 +- .../controller/PanelStaffProtoMapper.java | 40 +- .../controller/PublicStaffController.java | 6 +- .../staff/service/InvitationService.java | 6 +- .../staff/service/MinecraftStaffService.java | 278 ++++ .../staff/service/StaffLookupCache.java | 46 + .../staff/service/StaffProfileService.java | 75 ++ .../staff/service/StaffResponseFactory.java | 22 + .../backend/staff/service/StaffService.java | 386 +----- .../staff/service/StaffTwoFactorService.java | 17 +- .../service/SuperAdminStaffSynthesizer.java | 33 + .../storage/config/S3ClientFactory.java | 36 + .../storage/config/S3Configuration.java | 21 +- .../controller/EvidenceUploadController.java | 134 +- .../controller/PanelMediaController.java | 5 +- .../controller/PanelStorageController.java | 6 +- .../controller/PublicMediaController.java | 7 +- .../data/EvidenceUploadTokenDocument.java | 22 +- .../EvidenceUploadTokenMongoRepository.java | 4 +- .../service/EvidenceUploadService.java | 122 +- .../service/EvidenceUploadTokenService.java | 24 +- .../storage/service/MediaAccessService.java | 6 +- .../service/MediaValidationService.java | 42 +- .../storage/service/S3StorageService.java | 111 +- .../storage/service/StorageKeyUtils.java | 90 ++ .../service/StorageMetadataService.java | 35 +- .../storage/service/StorageQuotaService.java | 17 +- .../storage/service/StorageSyncService.java | 11 +- .../service/StorageUsageAccountant.java | 42 + .../MinecraftReportsController.java | 65 +- .../MinecraftReportsV3Controller.java | 6 +- .../MinecraftTicketProtoMapper.java | 280 ++-- .../MinecraftTicketsController.java | 101 +- .../MinecraftTicketsV3Controller.java | 19 +- .../controller/PanelTicketController.java | 3 + .../controller/PanelTicketProtoMapper.java | 31 +- .../controller/PublicTicketProtoMapper.java | 26 +- .../TicketSubscriptionController.java | 37 +- .../response/MinecraftPlayerTicketView.java | 13 + .../dto/response/MinecraftReportView.java | 23 + .../MinecraftTicketDetailReplyView.java | 13 + .../response/MinecraftTicketDetailView.java | 24 + .../response/MinecraftTicketListItemView.java | 22 + .../response/MinecraftTicketLookupView.java | 16 + .../dto/response/MinecraftV1Response.java | 45 + .../service/MinecraftTicketService.java | 218 ++-- .../ticket/service/TicketContentService.java | 18 - .../ticket/service/TicketIdGenerator.java | 2 +- .../service/TicketNotificationService.java | 97 +- .../ticket/service/TicketReplyService.java | 35 +- .../service/TicketResponseAssembler.java | 108 ++ .../ticket/service/TicketSearchService.java | 12 +- .../backend/ticket/service/TicketService.java | 200 +-- .../service/TicketSubscriptionService.java | 52 +- .../ticket/util/TicketAssigneeUtil.java | 9 +- .../AdminMonitoringControllerTest.java | 3 +- .../AdminProtoMapperGoldenTest.java | 225 ++++ .../service/AdminAnalyticsServiceTest.java | 35 +- .../service/AdminMonitoringServiceTest.java | 17 +- .../service/AdminSecurityServiceTest.java | 16 +- .../service/AITicketAnalysisServiceTest.java | 8 +- .../AdminSystemAlertControllerTest.java | 30 +- .../service/AnalyticsServiceOverviewTest.java | 25 +- .../AnalyticsServiceTicketAnalyticsTest.java | 59 + .../PublicAppealControllerTest.java | 3 +- .../audit/service/AuditServiceTest.java | 20 +- .../service/StaffPerformanceServiceTest.java | 109 ++ .../controller/PanelAuthControllerTest.java | 17 +- .../service/StripeWebhookServiceTest.java | 6 +- .../SubscriptionExpiryServiceTest.java | 4 +- .../service/UsageTrackingServiceTest.java | 4 +- .../database/MongoIndexModelGoldenTest.java | 139 ++ ...est.java => MongoIndexReconcilerTest.java} | 94 +- .../TenantSchemaBootstrapperTest.java | 95 ++ ...verRegistrationCleanupRepositoryTest.java} | 8 +- .../PanelHandlerMappingTestSupport.java | 137 ++ .../DynamicCorsConfigurationSourceTest.java | 14 +- .../GlobalExceptionHandlerProtoTest.java | 16 +- .../filter/PanelAuthorizationMatrixTest.java | 281 ++++ .../filter/PanelPermissionFilterTest.java | 280 +--- ...oupClassificationCharacterizationTest.java | 249 ++++ .../ProtoMessageConverterConfigTest.java | 26 +- .../validation/MigrationValidatorIpTest.java | 92 ++ .../MinecraftChatLogV3ControllerTest.java | 3 +- .../MinecraftDashboardV3ControllerTest.java | 5 +- ...MinecraftNotificationV3ControllerTest.java | 19 +- .../MinecraftPlayerV3ControllerTest.java | 241 ++-- ...necraftPunishmentTypeV3ControllerTest.java | 5 +- .../MinecraftPunishmentV3ControllerTest.java | 82 +- .../MinecraftReportsV3ControllerTest.java | 40 +- .../MinecraftRolesV3ControllerTest.java | 5 +- .../MinecraftStaffV3ControllerTest.java | 11 +- .../MinecraftSyncV3ControllerTest.java | 61 +- .../MinecraftTicketV3ControllerTest.java | 125 +- ...inecraftV3ApiKeyFilterIntegrationTest.java | 2 +- .../player/PlayerServicePersistenceTest.java | 10 +- .../PlayerServiceSearchRankingTest.java | 4 +- .../MinecraftPlayerV1EnvelopeGoldenTest.java | 335 +++++ .../player/data/PlayerDataViewTest.java | 92 ++ .../data/PlayerPersistenceRoundTripTest.java | 115 ++ .../punishment/PunishmentDataViewTest.java | 104 ++ .../service/DuplicatePlayerMergerTest.java | 2 +- .../service/MinecraftPlayerServiceTest.java | 23 +- .../service/MinecraftStartupServiceTest.java | 10 +- .../service/MinecraftSyncServiceTest.java | 19 +- .../player/service/MojangApiServiceTest.java | 80 ++ .../service/PlayerLookupServiceTest.java | 9 +- .../player/service/PunishmentMapperTest.java | 13 +- .../service/PunishmentQueryServiceTest.java | 17 +- .../player/service/PunishmentServiceTest.java | 14 +- .../service/PunishmentViewGoldenTest.java | 175 +++ .../SimplePunishmentViewGoldenTest.java | 204 +++ .../player/service/SyncResultGoldenTest.java | 83 ++ .../PublicPunishmentControllerTest.java | 9 +- .../PublicPunishmentProtoMapperTest.java | 26 +- .../ratelimit/RateLimitConfigTest.java | 3 +- .../auth/RealtimeTopicAuthorizerTest.java | 18 +- .../PublicRegistrationControllerTest.java | 4 + .../RegistrationServiceRateLimitTest.java | 91 ++ .../RegistrationCleanupServiceTest.java | 24 +- .../MinecraftReplayControllerTest.java | 3 +- .../data/ReplayLabelValidationTest.java | 17 + .../LegacyReplayCleanupServiceTest.java | 6 +- .../PlayerReplayListingServiceTest.java | 183 +++ .../replay/service/ReplayServiceTest.java | 157 +-- .../PublicReplayLiteControllerTest.java | 3 +- .../service/ReplayLiteAbuseGuardTest.java | 70 + .../service/ReplayLiteServiceTest.java | 2 - .../rest/PublicServerValidationTest.java | 3 +- .../backend/server/ServerServiceTest.java | 53 +- .../ServerProvisioningTeardownTest.java | 60 + .../service/ServerTimestampServiceTest.java | 4 +- .../service/ApiKeySettingsServiceTest.java | 155 ++- .../service/DomainSettingsServiceTest.java | 156 ++- .../MinecraftStaffControllerTest.java | 6 +- ... => MinecraftStaffServiceSummaryTest.java} | 13 +- ...StaffServiceSuperAdminAssignmentTest.java} | 15 +- ...aftStaffServiceUuidNormalizationTest.java} | 24 +- .../service/StaffServiceRoleSecurityTest.java | 9 +- .../service/StaffServiceStaffCountTest.java | 9 +- .../service/StaffTwoFactorServiceTest.java | 15 +- .../SuperAdminStaffSynthesizerTest.java | 67 + ...eUploadControllerCharacterizationTest.java | 411 ++++++ .../service/EvidenceUploadServiceTest.java | 19 +- .../service/StorageMetadataServiceTest.java | 9 +- .../service/StorageQuotaServiceTest.java | 20 +- .../service/StorageUsageAccountantTest.java | 99 ++ .../MinecraftTicketV1EnvelopeGoldenTest.java | 262 ++++ .../PublicTicketControllerTest.java | 3 +- .../MinecraftTicketProjectionWireTest.java | 216 ++++ .../ticket/service/TicketServiceTest.java | 2 +- .../validation/RequestDtoValidationTest.java | 81 +- 503 files changed, 15377 insertions(+), 10605 deletions(-) create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java create mode 100644 src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java create mode 100644 src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java create mode 100644 src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java create mode 100644 src/main/java/gg/modl/backend/audit/service/AdminDatabaseBrowserService.java create mode 100644 src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java create mode 100644 src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java rename src/main/java/gg/modl/backend/database/{MongoIndexBootstrapService.java => MongoIndexReconciler.java} (63%) create mode 100644 src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java delete mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerProvisioningRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java create mode 100644 src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java rename src/main/java/gg/modl/backend/database/mongo/repository/{AuditMongoRepository.java => StaffActivityAnalyticsRepository.java} (57%) delete mode 100644 src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/util/DigestUtils.java delete mode 100644 src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/util/UuidUtils.java create mode 100644 src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java rename src/main/java/gg/modl/backend/log/data/{SystemLog.java => ServerLog.java} (97%) delete mode 100644 src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java create mode 100644 src/main/java/gg/modl/backend/migration/service/MigrationMessages.java create mode 100644 src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java create mode 100644 src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java create mode 100644 src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java create mode 100644 src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java create mode 100644 src/main/java/gg/modl/backend/player/data/PlayerDataView.java create mode 100644 src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java create mode 100644 src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java delete mode 100644 src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java delete mode 100644 src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java create mode 100644 src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java create mode 100644 src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PardonResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java create mode 100644 src/main/java/gg/modl/backend/player/dto/response/SyncResult.java create mode 100644 src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java create mode 100644 src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java delete mode 100644 src/main/java/gg/modl/backend/server/ServerResponseMessage.java create mode 100644 src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java create mode 100644 src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java create mode 100644 src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java create mode 100644 src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java create mode 100644 src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java delete mode 100644 src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java delete mode 100644 src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java create mode 100644 src/main/java/gg/modl/backend/settings/service/VersionedSettingsSupport.java create mode 100644 src/main/java/gg/modl/backend/staff/service/MinecraftStaffService.java create mode 100644 src/main/java/gg/modl/backend/staff/service/StaffLookupCache.java create mode 100644 src/main/java/gg/modl/backend/staff/service/StaffProfileService.java create mode 100644 src/main/java/gg/modl/backend/staff/service/StaffResponseFactory.java create mode 100644 src/main/java/gg/modl/backend/staff/service/SuperAdminStaffSynthesizer.java create mode 100644 src/main/java/gg/modl/backend/storage/config/S3ClientFactory.java create mode 100644 src/main/java/gg/modl/backend/storage/service/StorageKeyUtils.java create mode 100644 src/main/java/gg/modl/backend/storage/service/StorageUsageAccountant.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftPlayerTicketView.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftReportView.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftTicketDetailReplyView.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftTicketDetailView.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftTicketListItemView.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftTicketLookupView.java create mode 100644 src/main/java/gg/modl/backend/ticket/dto/response/MinecraftV1Response.java create mode 100644 src/main/java/gg/modl/backend/ticket/service/TicketResponseAssembler.java create mode 100644 src/test/java/gg/modl/backend/admin/controller/AdminProtoMapperGoldenTest.java create mode 100644 src/test/java/gg/modl/backend/analytics/service/AnalyticsServiceTicketAnalyticsTest.java create mode 100644 src/test/java/gg/modl/backend/audit/service/StaffPerformanceServiceTest.java create mode 100644 src/test/java/gg/modl/backend/database/MongoIndexModelGoldenTest.java rename src/test/java/gg/modl/backend/database/{MongoIndexBootstrapServiceTest.java => MongoIndexReconcilerTest.java} (70%) create mode 100644 src/test/java/gg/modl/backend/database/TenantSchemaBootstrapperTest.java rename src/test/java/gg/modl/backend/database/mongo/repository/{ServerMongoRepositoryTest.java => ServerRegistrationCleanupRepositoryTest.java} (93%) create mode 100644 src/test/java/gg/modl/backend/infrastructure/authorization/PanelHandlerMappingTestSupport.java create mode 100644 src/test/java/gg/modl/backend/infrastructure/filter/PanelAuthorizationMatrixTest.java create mode 100644 src/test/java/gg/modl/backend/infrastructure/filter/RouteGroupClassificationCharacterizationTest.java create mode 100644 src/test/java/gg/modl/backend/migration/validation/MigrationValidatorIpTest.java create mode 100644 src/test/java/gg/modl/backend/player/controller/MinecraftPlayerV1EnvelopeGoldenTest.java create mode 100644 src/test/java/gg/modl/backend/player/data/PlayerDataViewTest.java create mode 100644 src/test/java/gg/modl/backend/player/data/PlayerPersistenceRoundTripTest.java create mode 100644 src/test/java/gg/modl/backend/player/data/punishment/PunishmentDataViewTest.java create mode 100644 src/test/java/gg/modl/backend/player/service/MojangApiServiceTest.java create mode 100644 src/test/java/gg/modl/backend/player/service/PunishmentViewGoldenTest.java create mode 100644 src/test/java/gg/modl/backend/player/service/SimplePunishmentViewGoldenTest.java create mode 100644 src/test/java/gg/modl/backend/player/service/SyncResultGoldenTest.java create mode 100644 src/test/java/gg/modl/backend/registration/RegistrationServiceRateLimitTest.java create mode 100644 src/test/java/gg/modl/backend/replay/service/PlayerReplayListingServiceTest.java create mode 100644 src/test/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuardTest.java create mode 100644 src/test/java/gg/modl/backend/server/service/ServerProvisioningTeardownTest.java rename src/test/java/gg/modl/backend/staff/service/{StaffServiceMinecraftSummaryTest.java => MinecraftStaffServiceSummaryTest.java} (90%) rename src/test/java/gg/modl/backend/staff/service/{StaffServiceSuperAdminAssignmentTest.java => MinecraftStaffServiceSuperAdminAssignmentTest.java} (91%) rename src/test/java/gg/modl/backend/staff/service/{StaffServiceUuidNormalizationTest.java => MinecraftStaffServiceUuidNormalizationTest.java} (80%) create mode 100644 src/test/java/gg/modl/backend/staff/service/SuperAdminStaffSynthesizerTest.java create mode 100644 src/test/java/gg/modl/backend/storage/controller/EvidenceUploadControllerCharacterizationTest.java create mode 100644 src/test/java/gg/modl/backend/storage/service/StorageUsageAccountantTest.java create mode 100644 src/test/java/gg/modl/backend/ticket/controller/MinecraftTicketV1EnvelopeGoldenTest.java create mode 100644 src/test/java/gg/modl/backend/ticket/service/MinecraftTicketProjectionWireTest.java diff --git a/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java b/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java index 1c52408..5eb5592 100644 --- a/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java +++ b/mongo-fields-processor/src/main/java/gg/modl/backend/database/mongo/codegen/MongoFieldsProcessor.java @@ -380,7 +380,7 @@ private String annotationStringValue(AnnotationMirror mirror, String propertyNam } for (final Map.Entry entry : - processingEnv.getElementUtils().getElementValuesWithDefaults(mirror).entrySet()) { + elements.getElementValuesWithDefaults(mirror).entrySet()) { if (propertyName.equals(entry.getKey().getSimpleName().toString())) { Object value = entry.getValue().getValue(); 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 ffb8a70..a4b227a 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsController.java @@ -6,7 +6,6 @@ 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; @@ -42,11 +41,7 @@ public ResponseEntity getUsage() { 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)); + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toHistoricalResponse(adminAnalyticsService.getHistorical(metric, range))); } @PostMapping("/export") @@ -54,17 +49,14 @@ public ResponseEntity exportAnalytics(@RequestBody ExportAnalyticsRequest req 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)); + .body(adminAnalyticsService.exportCsv(range)); + } + if ("json".equals(type)) { + return ResponseEntity.ok(AdminAnalyticsProtoMapper.toExportResponse(adminAnalyticsService.exportJson(range))); } throw new ValidationException("Invalid export type"); diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java index 6ea2ad8..d0f1252 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAnalyticsProtoMapper.java @@ -1,6 +1,12 @@ package gg.modl.backend.admin.controller; -import gg.modl.backend.server.data.Server; +import gg.modl.backend.admin.dto.response.AdminAnalyticsActivity; +import gg.modl.backend.admin.dto.response.AdminAnalyticsDashboard; +import gg.modl.backend.admin.dto.response.AdminAnalyticsExport; +import gg.modl.backend.admin.dto.response.AdminAnalyticsHistorical; +import gg.modl.backend.admin.dto.response.AdminAnalyticsUsage; +import gg.modl.backend.admin.dto.response.AdminNameCount; +import gg.modl.backend.admin.dto.response.AdminRegistrationPoint; import gg.modl.proto.modl.v1.AdminAnalyticsActivityPoint; import gg.modl.proto.modl.v1.AdminAnalyticsActivityResponse; import gg.modl.proto.modl.v1.AdminAnalyticsDashboardData; @@ -15,6 +21,7 @@ import gg.modl.proto.modl.v1.AdminAnalyticsNameValue; import gg.modl.proto.modl.v1.AdminAnalyticsOverview; import gg.modl.proto.modl.v1.AdminAnalyticsPlayerActivity; +import gg.modl.proto.modl.v1.AdminAnalyticsResourceUtilization; import gg.modl.proto.modl.v1.AdminAnalyticsServerActivity; import gg.modl.proto.modl.v1.AdminAnalyticsServerMetrics; import gg.modl.proto.modl.v1.AdminAnalyticsSystemHealth; @@ -22,17 +29,7 @@ import gg.modl.proto.modl.v1.AdminAnalyticsUsageResponse; import gg.modl.proto.modl.v1.AdminAnalyticsUsageStatistics; import gg.modl.proto.modl.v1.AdminAnalyticsUserEngagement; -import gg.modl.proto.modl.v1.AdminAnalyticsResourceUtilization; -import java.util.List; -import java.util.Map; - -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.doubleValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; final class AdminAnalyticsProtoMapper { @@ -40,50 +37,42 @@ final class AdminAnalyticsProtoMapper { private AdminAnalyticsProtoMapper() { } - static AdminAnalyticsDashboardResponse toDashboardResponse(Map response) { - Map data = map(response.get("data")); - Map overview = map(data.get("overview")); - Map serverMetrics = map(data.get("serverMetrics")); - Map usageStatistics = map(data.get("usageStatistics")); - - AdminAnalyticsDashboardData.Builder builder = AdminAnalyticsDashboardData.newBuilder() - .setOverview(toOverview(overview)) - .setServerMetrics(toServerMetrics(serverMetrics)) - .setUsageStatistics(toUsageStatistics(usageStatistics)) - .setSystemHealth(AdminAnalyticsSystemHealth.newBuilder().build()); + static AdminAnalyticsDashboardResponse toDashboardResponse(AdminAnalyticsDashboard data) { + AdminAnalyticsDashboardData dashboard = AdminAnalyticsDashboardData.newBuilder() + .setOverview(toOverview(data.overview())) + .setServerMetrics(toServerMetrics(data.serverMetrics())) + .setUsageStatistics(toUsageStatistics(data.usageStatistics())) + .setSystemHealth(AdminAnalyticsSystemHealth.newBuilder().build()) + .build(); return AdminAnalyticsDashboardResponse.newBuilder() .setSuccess(true) - .setData(builder.build()) + .setData(dashboard) .build(); } - static AdminAnalyticsActivityResponse toActivityResponse(Map response) { + static AdminAnalyticsActivityResponse toActivityResponse(AdminAnalyticsActivity response) { AdminAnalyticsActivityResponse.Builder builder = AdminAnalyticsActivityResponse.newBuilder() .setSuccess(true) - .setTotalPlayers(longValue(response.get("totalPlayers"))) - .setTotalServers(longValue(response.get("totalServers"))); - listOfMaps(response.get("data")).forEach(point -> builder.addData(AdminAnalyticsActivityPoint.newBuilder() - .setDate(stringValue(point.get("date"))) - .setActiveServers(longValue(point.get("activeServers"))) - .setOnlinePlayers(intValue(point.get("onlinePlayers"))) + .setTotalPlayers(response.totalPlayers()) + .setTotalServers(response.totalServers()); + response.data().forEach(point -> builder.addData(AdminAnalyticsActivityPoint.newBuilder() + .setDate(stringValue(point.date())) + .setActiveServers(point.activeServers()) + .setOnlinePlayers(point.onlinePlayers()) .build())); return builder.build(); } - static AdminAnalyticsUsageResponse toUsageResponse(Map response) { - Map data = map(response.get("data")); - Map userEngagement = map(data.get("userEngagement")); - Map resourceUtilization = map(data.get("resourceUtilization")); - + static AdminAnalyticsUsageResponse toUsageResponse(AdminAnalyticsUsage response) { AdminAnalyticsUsageData usageData = AdminAnalyticsUsageData.newBuilder() .setUserEngagement(AdminAnalyticsUserEngagement.newBuilder() - .setMonthlyActiveServers(longValue(userEngagement.get("monthlyActiveServers"))) + .setMonthlyActiveServers(response.monthlyActiveServers()) .build()) .setResourceUtilization(AdminAnalyticsResourceUtilization.newBuilder() - .setStorage(longValue(resourceUtilization.get("storage"))) - .setStoragePercent(doubleValue(resourceUtilization.get("storagePercent"))) - .setApiCalls(longValue(resourceUtilization.get("apiCalls"))) - .setDatabaseQueries(longValue(resourceUtilization.get("databaseQueries"))) + .setStorage(response.storage()) + .setStoragePercent(response.storagePercent()) + .setApiCalls(response.apiCalls()) + .setDatabaseQueries(response.databaseQueries()) .build()) .build(); return AdminAnalyticsUsageResponse.newBuilder() @@ -92,136 +81,100 @@ static AdminAnalyticsUsageResponse toUsageResponse(Map response) .build(); } - static AdminAnalyticsHistoricalResponse toHistoricalResponse(Map response) { - Map data = map(response.get("data")); + static AdminAnalyticsHistoricalResponse toHistoricalResponse(AdminAnalyticsHistorical response) { AdminAnalyticsHistoricalData.Builder dataBuilder = AdminAnalyticsHistoricalData.newBuilder() - .setMetric(stringValue(data.get("metric"))) - .setRange(stringValue(data.get("range"))); - list(data.get("data")).forEach(entry -> { - Map point = map(entry); - dataBuilder.addData(AdminAnalyticsDateValue.newBuilder() - .setDate(stringValue(readField(entry, point, "date"))) - .setValue(longValue(readField(entry, point, "value"))) - .build()); - }); + .setMetric(stringValue(response.metric())) + .setRange(stringValue(response.range())); + response.data().forEach(point -> dataBuilder.addData(AdminAnalyticsDateValue.newBuilder() + .setDate(stringValue(point.date())) + .setValue(point.value()) + .build())); return AdminAnalyticsHistoricalResponse.newBuilder() .setSuccess(true) .setData(dataBuilder.build()) .build(); } - static AdminAnalyticsExportResponse toExportResponse(Map response) { - Map data = map(response.get("data")); + static AdminAnalyticsExportResponse toExportResponse(AdminAnalyticsExport response) { return AdminAnalyticsExportResponse.newBuilder() - .setExportDate(stringValue(response.get("exportDate"))) - .setRange(stringValue(response.get("range"))) + .setExportDate(stringValue(response.exportDate())) + .setRange(stringValue(response.range())) .setData(AdminAnalyticsExportData.newBuilder() - .setServers(longValue(data.get("servers"))) - .setUsers(longValue(data.get("users"))) - .setTickets(longValue(data.get("tickets"))) + .setServers(response.servers()) + .setUsers(response.users()) + .setTickets(response.tickets()) .build()) .build(); } - private static AdminAnalyticsOverview toOverview(Map overview) { + private static AdminAnalyticsOverview toOverview(AdminAnalyticsDashboard.Overview overview) { return AdminAnalyticsOverview.newBuilder() - .setTotalServers(longValue(overview.get("totalServers"))) - .setActiveServers(longValue(overview.get("activeServers"))) - .setTotalUsers(longValue(overview.get("totalUsers"))) - .setTotalTickets(longValue(overview.get("totalTickets"))) - .setServerGrowthRate(stringValue(overview.get("serverGrowthRate"))) - .setUserGrowthRate(stringValue(overview.get("userGrowthRate"))) - .setAvgPlayersPerServer(stringValue(overview.get("avgPlayersPerServer"))) - .setAvgTicketsPerServer(stringValue(overview.get("avgTicketsPerServer"))) + .setTotalServers(overview.totalServers()) + .setActiveServers(overview.activeServers()) + .setTotalUsers(overview.totalUsers()) + .setTotalTickets(overview.totalTickets()) + .setServerGrowthRate(overview.serverGrowthRate()) + .setUserGrowthRate(overview.userGrowthRate()) + .setAvgPlayersPerServer(overview.avgPlayersPerServer()) + .setAvgTicketsPerServer(overview.avgTicketsPerServer()) .build(); } - private static AdminAnalyticsServerMetrics toServerMetrics(Map serverMetrics) { + private static AdminAnalyticsServerMetrics toServerMetrics(AdminAnalyticsDashboard.ServerMetrics serverMetrics) { AdminAnalyticsServerMetrics.Builder builder = AdminAnalyticsServerMetrics.newBuilder(); - toNameValues(serverMetrics.get("byPlan")).forEach(builder::addByPlan); - toNameValues(serverMetrics.get("byStatus")).forEach(builder::addByStatus); - list(serverMetrics.get("registrationTrend")).forEach(entry -> - builder.addRegistrationTrend(toDateServers(entry))); + serverMetrics.byPlan().forEach(count -> builder.addByPlan(toNameValue(count))); + serverMetrics.byStatus().forEach(count -> builder.addByStatus(toNameValue(count))); + serverMetrics.registrationTrend().forEach(point -> builder.addRegistrationTrend(toDateServers(point))); return builder.build(); } - private static AdminAnalyticsUsageStatistics toUsageStatistics(Map usageStatistics) { + private static AdminAnalyticsUsageStatistics toUsageStatistics(AdminAnalyticsDashboard.UsageStatistics usageStatistics) { AdminAnalyticsUsageStatistics.Builder builder = AdminAnalyticsUsageStatistics.newBuilder() - .setTotalPlayerCount(intValue(usageStatistics.get("totalPlayerCount"))); - list(usageStatistics.get("topServersByUsers")).stream() - .filter(Server.class::isInstance) - .map(Server.class::cast) - .forEach(server -> builder.addTopServersByUsers(AdminServerProtoMapper.toRecord(server))); - listOfMaps(usageStatistics.get("serverActivity")).forEach(activity -> + .setTotalPlayerCount(usageStatistics.totalPlayerCount()); + usageStatistics.topServersByUsers().forEach(server -> + builder.addTopServersByUsers(AdminServerProtoMapper.toRecord(server))); + usageStatistics.serverActivity().forEach(activity -> builder.addServerActivity(AdminAnalyticsServerActivity.newBuilder() - .setDate(stringValue(activity.get("date"))) - .setActiveServers(longValue(activity.get("activeServers"))) + .setDate(stringValue(activity.date())) + .setActiveServers(activity.activeServers()) .build())); - listOfMaps(usageStatistics.get("liveServers")).forEach(live -> - builder.addLiveServers(toLiveServer(live))); - listOfMaps(usageStatistics.get("playerActivity")).forEach(activity -> + usageStatistics.liveServers().forEach(live -> builder.addLiveServers(toLiveServer(live))); + usageStatistics.playerActivity().forEach(activity -> builder.addPlayerActivity(AdminAnalyticsPlayerActivity.newBuilder() - .setDate(stringValue(activity.get("date"))) - .setPlayers(intValue(activity.get("players"))) + .setDate(stringValue(activity.date())) + .setPlayers(activity.players()) .build())); return builder.build(); } - private static AdminAnalyticsLiveServer toLiveServer(Map live) { + private static AdminAnalyticsLiveServer toLiveServer(AdminAnalyticsDashboard.LiveServer live) { AdminAnalyticsLiveServer.Builder builder = AdminAnalyticsLiveServer.newBuilder() - .setServerId(stringValue(live.get("serverId"))) - .setServerName(stringValue(live.get("serverName"))) - .setPlayerCount(intValue(live.get("playerCount"))); - if (live.get("platform") != null) { - builder.setPlatform(stringValue(live.get("platform"))); + .setServerId(stringValue(live.serverId())) + .setServerName(stringValue(live.serverName())) + .setPlayerCount(live.playerCount()); + if (live.platform() != null) { + builder.setPlatform(live.platform()); } - if (live.get("version") != null) { - builder.setVersion(stringValue(live.get("version"))); + if (live.version() != null) { + builder.setVersion(live.version()); } - if (live.get("pluginVersion") != null) { - builder.setPluginVersion(stringValue(live.get("pluginVersion"))); + if (live.pluginVersion() != null) { + builder.setPluginVersion(live.pluginVersion()); } return builder.build(); } - private static List toNameValues(Object source) { - return list(source).stream() - .map(AdminAnalyticsProtoMapper::toNameValue) - .toList(); - } - - private static AdminAnalyticsNameValue toNameValue(Object source) { - if (source instanceof gg.modl.backend.database.mongo.repository.ServerMongoRepository.NameValueResult result) { - return AdminAnalyticsNameValue.newBuilder() - .setName(stringValue(result.name())) - .setValue(result.value()) - .build(); - } - Map map = map(source); + private static AdminAnalyticsNameValue toNameValue(AdminNameCount count) { return AdminAnalyticsNameValue.newBuilder() - .setName(stringValue(map.get("name"))) - .setValue(intValue(map.get("value"))) + .setName(stringValue(count.name())) + .setValue(count.value()) .build(); } - private static AdminAnalyticsDateServers toDateServers(Object source) { - if (source instanceof gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateServersResult result) { - return AdminAnalyticsDateServers.newBuilder() - .setDate(stringValue(result.date())) - .setServers(result.servers()) - .build(); - } - Map map = map(source); + private static AdminAnalyticsDateServers toDateServers(AdminRegistrationPoint point) { return AdminAnalyticsDateServers.newBuilder() - .setDate(stringValue(map.get("date"))) - .setServers(intValue(map.get("servers"))) + .setDate(stringValue(point.date())) + .setServers(point.servers()) .build(); } - - private static Object readField(Object source, Map map, String field) { - if (source instanceof gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateValueResult result) { - return field.equals("date") ? result.date() : result.value(); - } - return map.get(field); - } } 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 e32ece9..630a3ff 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminAuthController.java @@ -31,7 +31,7 @@ public class AdminAuthController { private final AuthService authService; private final SessionService sessionService; private final CookieUtil cookieUtil; - private static final long SESSION_MAX_AGE = 24 * 60 * 60; // 24 hours + private static final long SESSION_MAX_AGE = 24 * 60 * 60; @PostMapping("/request-code") public ResponseEntity requestCode(@RequestBody AdminRequestCodeRequest request) throws Exception { @@ -50,7 +50,6 @@ public ResponseEntity login( 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()); 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 f6fa673..a3dba26 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringController.java @@ -5,6 +5,10 @@ import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.proto.modl.v1.CreateSystemLogRequest; +import gg.modl.proto.modl.v1.DeleteLogsRequest; +import gg.modl.proto.modl.v1.ResolveLogRequest; +import gg.modl.proto.modl.v1.TogglePm2Request; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import java.util.Date; @@ -63,7 +67,7 @@ public ResponseEntity getLogs( } @PostMapping("/logs") - public ResponseEntity createLog(@RequestBody gg.modl.proto.modl.v1.CreateSystemLogRequest request) { + public ResponseEntity createLog(@RequestBody CreateSystemLogRequest request) { SystemLog saved = adminMonitoringService.createLog(AdminMonitoringProtoMapper.fromCreateLog(request)); return ResponseEntity.status(201).body( AdminMonitoringProtoMapper.toSystemLogMutationResponse(saved, "Log entry created successfully")); @@ -75,7 +79,7 @@ public ResponseEntity getSources() { } @PutMapping("/logs/{id}/resolve") - public ResponseEntity resolveLog(@PathVariable String id, @RequestBody gg.modl.proto.modl.v1.ResolveLogRequest request) { + public ResponseEntity resolveLog(@PathVariable String id, @RequestBody ResolveLogRequest request) { SystemLog updated = adminMonitoringService.resolveLog(id, AdminMonitoringProtoMapper.fromResolveLog(request)).orElse(null); if (updated == null) { throw new ResourceNotFoundException("Log entry not found"); @@ -90,7 +94,7 @@ public ResponseEntity getHealth() { } @PostMapping("/logs/delete") - public ResponseEntity deleteLogs(@RequestBody gg.modl.proto.modl.v1.DeleteLogsRequest request) { + public ResponseEntity deleteLogs(@RequestBody DeleteLogsRequest request) { long deletedCount = adminMonitoringService.deleteLogs(request.getLogIdsList()); return ResponseEntity.ok(AdminMonitoringProtoMapper.toDeleteLogsResponse( deletedCount, "Successfully deleted " + deletedCount + " log(s)")); @@ -121,7 +125,6 @@ public ResponseEntity clearAllLogs() { @GetMapping("/pm2-status") public ResponseEntity getPm2Status() { - // PM2 integration placeholder - would require native process monitoring return ResponseEntity.ok(AdminMonitoringProtoMapper.toPm2StatusResponse()); } @@ -131,7 +134,7 @@ public ResponseEntity restartPm2() { } @PostMapping("/pm2/toggle") - public ResponseEntity togglePm2(@RequestBody gg.modl.proto.modl.v1.TogglePm2Request request) { + public ResponseEntity togglePm2(@RequestBody 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/AdminMonitoringProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java index 2516eba..6a94440 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminMonitoringProtoMapper.java @@ -3,6 +3,11 @@ 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.admin.dto.response.AdminMonitoringDashboard; +import gg.modl.backend.admin.dto.response.AdminMonitoringHealth; +import gg.modl.backend.admin.dto.response.AdminMonitoringLogs; +import gg.modl.backend.admin.dto.response.AdminMonitoringSources; +import gg.modl.backend.admin.dto.response.AdminPagination; import gg.modl.proto.modl.v1.AdminMonitoringDashboardData; import gg.modl.proto.modl.v1.AdminMonitoringDashboardResponse; import gg.modl.proto.modl.v1.AdminMonitoringDeleteLogsResponse; @@ -30,14 +35,10 @@ import gg.modl.proto.modl.v1.SystemLogResponse; import java.util.Map; -import java.util.function.Consumer; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.doubleValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.structToMap; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toStruct; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -48,7 +49,7 @@ private AdminMonitoringProtoMapper() { static CreateSystemLogRequest fromCreateLog(gg.modl.proto.modl.v1.CreateSystemLogRequest request) { Map metadata = request.hasMetadata() - ? gg.modl.backend.infrastructure.proto.ProtoMapperSupport.structToMap(request.getMetadata()) + ? structToMap(request.getMetadata()) : null; return new CreateSystemLogRequest( request.getLevel(), @@ -64,44 +65,40 @@ static ResolveLogRequest fromResolveLog(gg.modl.proto.modl.v1.ResolveLogRequest return new ResolveLogRequest(request.hasResolvedBy() ? request.getResolvedBy() : null); } - static AdminMonitoringDashboardResponse toDashboardResponse(Map response) { - Map data = map(response.get("data")); - Map servers = map(data.get("servers")); - Map logs = map(data.get("logs")); - Map last24h = map(logs.get("last24h")); - Map unresolved = map(logs.get("unresolved")); - Map systemHealth = map(data.get("systemHealth")); + static AdminMonitoringDashboardResponse toDashboardResponse(AdminMonitoringDashboard response) { + AdminMonitoringDashboard.ServerMetrics servers = response.servers(); + AdminMonitoringDashboard.LogMetrics logs = response.logs(); + AdminMonitoringDashboard.SystemHealth systemHealth = response.systemHealth(); AdminMonitoringDashboardData.Builder builder = AdminMonitoringDashboardData.newBuilder() .setServers(AdminMonitoringServerMetrics.newBuilder() - .setTotal(longValue(servers.get("total"))) - .setActive(longValue(servers.get("active"))) - .setPending(longValue(servers.get("pending"))) - .setFailed(longValue(servers.get("failed"))) - .setRecentRegistrations(longValue(servers.get("recentRegistrations"))) - .setConcurrentServers(longValue(servers.get("concurrentServers"))) - .setConcurrentPlayers(longValue(servers.get("concurrentPlayers"))) + .setTotal(servers.total()) + .setActive(servers.active()) + .setPending(servers.pending()) + .setFailed(servers.failed()) + .setRecentRegistrations(servers.recentRegistrations()) + .setConcurrentServers(servers.concurrentServers()) + .setConcurrentPlayers(servers.concurrentPlayers()) .build()) .setLogs(AdminMonitoringLogMetrics.newBuilder() .setLast24H(AdminMonitoringLogWindow.newBuilder() - .setTotal(longValue(last24h.get("total"))) - .setCritical(longValue(last24h.get("critical"))) - .setError(longValue(last24h.get("error"))) - .setWarning(longValue(last24h.get("warning"))) + .setTotal(logs.last24h().total()) + .setCritical(logs.last24h().critical()) + .setError(logs.last24h().error()) + .setWarning(logs.last24h().warning()) .build()) .setUnresolved(AdminMonitoringUnresolvedLogs.newBuilder() - .setCritical(longValue(unresolved.get("critical"))) - .setError(longValue(unresolved.get("error"))) + .setCritical(logs.unresolved().critical()) + .setError(logs.unresolved().error()) .build()) .build()) .setSystemHealth(AdminMonitoringSystemHealthSummary.newBuilder() - .setScore(doubleValue(systemHealth.get("score"))) - .setStatus(stringValue(systemHealth.get("status"))) + .setScore(systemHealth.score()) + .setStatus(systemHealth.status()) .build()); - listOfMaps(data.get("trends")).forEach(trend -> builder.addTrends(toStruct(trend))); - Object lastUpdated = data.get("lastUpdated"); - if (lastUpdated != null) { - builder.setLastUpdated(toTimestamp(lastUpdated)); + response.trends().forEach(trend -> builder.addTrends(toStruct(trend))); + if (response.lastUpdated() != null) { + builder.setLastUpdated(toTimestamp(response.lastUpdated())); } return AdminMonitoringDashboardResponse.newBuilder() .setSuccess(true) @@ -109,23 +106,11 @@ static AdminMonitoringDashboardResponse toDashboardResponse(Map .build(); } - static AdminMonitoringLogsResponse toLogsResponse(Map response) { - Map data = map(response.get("data")); - Map pagination = map(data.get("pagination")); - Map filters = map(data.get("filters")); - + static AdminMonitoringLogsResponse toLogsResponse(AdminMonitoringLogs response) { AdminMonitoringLogsData.Builder dataBuilder = AdminMonitoringLogsData.newBuilder() - .setPagination(AdminMonitoringPagination.newBuilder() - .setPage((int) longValue(pagination.get("page"))) - .setLimit((int) longValue(pagination.get("limit"))) - .setTotal(longValue(pagination.get("total"))) - .setPages((int) longValue(pagination.get("pages"))) - .build()) - .setFilters(toLogFilters(filters)); - list(data.get("logs")).stream() - .filter(SystemLog.class::isInstance) - .map(SystemLog.class::cast) - .forEach(systemLog -> dataBuilder.addLogs(toSystemLogResponse(systemLog))); + .setPagination(toPagination(response.pagination())) + .setFilters(toLogFilters(response.filters())); + response.logs().forEach(systemLog -> dataBuilder.addLogs(toSystemLogResponse(systemLog))); return AdminMonitoringLogsResponse.newBuilder() .setSuccess(true) .setData(dataBuilder.build()) @@ -142,25 +127,23 @@ static AdminMonitoringSystemLogMutationResponse toSystemLogMutationResponse(Syst return builder.build(); } - static AdminMonitoringSourcesResponse toSourcesResponse(Map response) { - Map data = map(response.get("data")); - AdminMonitoringSourcesData.Builder dataBuilder = AdminMonitoringSourcesData.newBuilder(); - list(data.get("sources")).forEach(source -> dataBuilder.addSources(stringValue(source))); - list(data.get("categories")).forEach(category -> dataBuilder.addCategories(stringValue(category))); + static AdminMonitoringSourcesResponse toSourcesResponse(AdminMonitoringSources response) { + AdminMonitoringSourcesData dataBuilder = AdminMonitoringSourcesData.newBuilder() + .addAllSources(response.sources()) + .addAllCategories(response.categories()) + .build(); return AdminMonitoringSourcesResponse.newBuilder() .setSuccess(true) - .setData(dataBuilder.build()) + .setData(dataBuilder) .build(); } - static AdminMonitoringHealthResponse toHealthResponse(Map response) { - Map data = map(response.get("data")); + static AdminMonitoringHealthResponse toHealthResponse(AdminMonitoringHealth response) { AdminMonitoringHealthData.Builder dataBuilder = AdminMonitoringHealthData.newBuilder() - .setStatus(stringValue(data.get("status"))); - listOfMaps(data.get("checks")).forEach(check -> dataBuilder.addChecks(toHealthCheck(check))); - Object timestamp = data.get("timestamp"); - if (timestamp != null) { - dataBuilder.setTimestamp(toTimestamp(timestamp)); + .setStatus(stringValue(response.status())); + response.checks().forEach(check -> dataBuilder.addChecks(toHealthCheck(check))); + if (response.timestamp() != null) { + dataBuilder.setTimestamp(toTimestamp(response.timestamp())); } return AdminMonitoringHealthResponse.newBuilder() .setSuccess(true) @@ -212,38 +195,41 @@ static AdminMonitoringPm2ToggleResponse toPm2ToggleResponse(boolean enabled, Str return builder.build(); } - private static AdminMonitoringLogFilters toLogFilters(Map filters) { - AdminMonitoringLogFilters.Builder builder = AdminMonitoringLogFilters.newBuilder(); - setFilter(filters.get("level"), builder::setLevel); - setFilter(filters.get("source"), builder::setSource); - setFilter(filters.get("serverId"), builder::setServerId); - setFilter(filters.get("category"), builder::setCategory); - setFilter(filters.get("resolved"), builder::setResolved); - setFilter(filters.get("search"), builder::setSearch); - return builder.build(); + private static AdminMonitoringPagination toPagination(AdminPagination pagination) { + return AdminMonitoringPagination.newBuilder() + .setPage(pagination.page()) + .setLimit(pagination.limit()) + .setTotal(pagination.total()) + .setPages(pagination.pages()) + .build(); } - private static void setFilter(Object value, Consumer setter) { - if (value != null) { - setter.accept(stringValue(value)); - } + private static AdminMonitoringLogFilters toLogFilters(AdminMonitoringLogs.Filters filters) { + AdminMonitoringLogFilters.Builder builder = AdminMonitoringLogFilters.newBuilder(); + setOptionalString(builder::setLevel, filters.level()); + setOptionalString(builder::setSource, filters.source()); + setOptionalString(builder::setServerId, filters.serverId()); + setOptionalString(builder::setCategory, filters.category()); + setOptionalString(builder::setResolved, filters.resolved()); + setOptionalString(builder::setSearch, filters.search()); + return builder.build(); } - private static AdminMonitoringHealthCheck toHealthCheck(Map check) { + private static AdminMonitoringHealthCheck toHealthCheck(AdminMonitoringHealth.HealthCheck check) { AdminMonitoringHealthCheck.Builder builder = AdminMonitoringHealthCheck.newBuilder() - .setName(stringValue(check.get("name"))) - .setStatus(stringValue(check.get("status"))); - if (check.get("message") != null) { - builder.setMessage(stringValue(check.get("message"))); + .setName(stringValue(check.name())) + .setStatus(stringValue(check.status())); + if (check.message() != null) { + builder.setMessage(check.message()); } - if (check.get("responseTime") != null) { - builder.setResponseTime(longValue(check.get("responseTime"))); + if (check.responseTime() != null) { + builder.setResponseTime(check.responseTime()); } - if (check.get("error") != null) { - builder.setError(stringValue(check.get("error"))); + if (check.error() != null) { + builder.setError(check.error()); } - if (check.get("count") != null) { - builder.setCount(longValue(check.get("count"))); + if (check.count() != null) { + builder.setCount(check.count()); } return builder.build(); } diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java index bb25d69..d986514 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSecurityProtoMapper.java @@ -1,6 +1,9 @@ package gg.modl.backend.admin.controller; import gg.modl.backend.admin.data.SecurityEvent; +import gg.modl.backend.admin.dto.response.AdminPagination; +import gg.modl.backend.admin.dto.response.AdminSecurityEvents; +import gg.modl.backend.admin.dto.response.AdminSecuritySummary; import gg.modl.proto.modl.v1.AdminSecurityEvent; import gg.modl.proto.modl.v1.AdminSecurityEventsData; import gg.modl.proto.modl.v1.AdminSecurityEventsResponse; @@ -10,11 +13,6 @@ import gg.modl.proto.modl.v1.AdminSecuritySummaryData; import gg.modl.proto.modl.v1.AdminSecuritySummaryResponse; -import java.util.Map; - -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -23,44 +21,29 @@ final class AdminSecurityProtoMapper { private AdminSecurityProtoMapper() { } - static AdminSecurityEventsResponse toEventsResponse(Map response) { - Map data = map(response.get("data")); - Map pagination = map(data.get("pagination")); - + static AdminSecurityEventsResponse toEventsResponse(AdminSecurityEvents response) { AdminSecurityEventsData.Builder dataBuilder = AdminSecurityEventsData.newBuilder() - .setPagination(AdminSecurityPagination.newBuilder() - .setPage((int) longValue(pagination.get("page"))) - .setLimit((int) longValue(pagination.get("limit"))) - .setTotal(longValue(pagination.get("total"))) - .setPages((int) longValue(pagination.get("pages"))) - .build()); - list(data.get("events")).stream() - .filter(SecurityEvent.class::isInstance) - .map(SecurityEvent.class::cast) - .forEach(event -> dataBuilder.addEvents(toEvent(event))); + .setPagination(toPagination(response.pagination())); + response.events().forEach(event -> dataBuilder.addEvents(toEvent(event))); return AdminSecurityEventsResponse.newBuilder() .setSuccess(true) .setData(dataBuilder.build()) .build(); } - static AdminSecuritySummaryResponse toSummaryResponse(Map response) { - Map data = map(response.get("data")); - Map last24Hours = map(data.get("last24Hours")); - Map last7Days = map(data.get("last7Days")); - + static AdminSecuritySummaryResponse toSummaryResponse(AdminSecuritySummary response) { + AdminSecuritySummary.Last24Hours last24Hours = response.last24Hours(); AdminSecuritySummaryData.Builder dataBuilder = AdminSecuritySummaryData.newBuilder() .setLast24Hours(AdminSecurityLast24Hours.newBuilder() - .setCritical(longValue(last24Hours.get("critical"))) - .setHigh(longValue(last24Hours.get("high"))) - .setMedium(longValue(last24Hours.get("medium"))) + .setCritical(last24Hours.critical()) + .setHigh(last24Hours.high()) + .setMedium(last24Hours.medium()) .build()) .setLast7Days(AdminSecurityLast7Days.newBuilder() - .setTotal(longValue(last7Days.get("total"))) + .setTotal(response.last7DaysTotal()) .build()); - Object timestamp = data.get("timestamp"); - if (timestamp != null) { - dataBuilder.setTimestamp(toTimestamp(timestamp)); + if (response.timestamp() != null) { + dataBuilder.setTimestamp(toTimestamp(response.timestamp())); } return AdminSecuritySummaryResponse.newBuilder() .setSuccess(true) @@ -68,6 +51,15 @@ static AdminSecuritySummaryResponse toSummaryResponse(Map respon .build(); } + private static AdminSecurityPagination toPagination(AdminPagination pagination) { + return AdminSecurityPagination.newBuilder() + .setPage(pagination.page()) + .setLimit(pagination.limit()) + .setTotal(pagination.total()) + .setPages(pagination.pages()) + .build(); + } + private static AdminSecurityEvent toEvent(SecurityEvent event) { AdminSecurityEvent.Builder builder = AdminSecurityEvent.newBuilder() .setId(stringValue(event.getId())) 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 85eaa49..c9fadf9 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminServerController.java @@ -13,6 +13,7 @@ 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.AdminServerUsageBatchRequest; import gg.modl.proto.modl.v1.UpdateServerRequest; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -64,7 +65,7 @@ public ResponseEntity getServers( } @PostMapping("/usage/batch") - public ResponseEntity getUsageBatch(@RequestBody gg.modl.proto.modl.v1.AdminServerUsageBatchRequest request) { + public ResponseEntity getUsageBatch(@RequestBody AdminServerUsageBatchRequest request) { List serverIds = request.getServerIdsList(); if (serverIds.isEmpty()) { throw new ValidationException("Missing required field: serverIds"); 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 b9a8ef0..84e05fb 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSystemController.java @@ -3,12 +3,15 @@ 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.dto.response.AdminMaintenanceStatus; import gg.modl.backend.admin.service.GlobalSystemService; import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.proto.modl.v1.UpdatePromptRequest; +import gg.modl.proto.modl.v1.UpdateRateLimitsRequest; +import gg.modl.proto.modl.v1.UpdateSystemConfigRequest; 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; @@ -34,7 +37,7 @@ public ResponseEntity getConfig() { } @PutMapping("/config") - public ResponseEntity updateConfig(@RequestBody gg.modl.proto.modl.v1.UpdateSystemConfigRequest request) { + public ResponseEntity updateConfig(@RequestBody UpdateSystemConfigRequest request) { SystemConfig saved = globalSystemService.updateConfig(AdminSystemProtoMapper.fromUpdateConfig(request)); log.info("Configuration updated by admin"); return ResponseEntity.ok(AdminSystemProtoMapper.toConfigResponse(saved, "Configuration updated successfully")); @@ -49,7 +52,7 @@ public ResponseEntity getMaintenanceStatus() { 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); + AdminMaintenanceStatus data = globalSystemService.toggleMaintenance(domainRequest); log.info("Maintenance mode {} by admin", enabled ? "enabled" : "disabled"); return ResponseEntity.ok(AdminSystemProtoMapper.toMaintenanceResponse( data, "Maintenance mode " + (enabled ? "enabled" : "disabled"))); @@ -61,7 +64,7 @@ public ResponseEntity getRateLimits() { } @PutMapping("/rate-limits") - public ResponseEntity updateRateLimits(@RequestBody gg.modl.proto.modl.v1.UpdateRateLimitsRequest request) { + public ResponseEntity updateRateLimits(@RequestBody UpdateRateLimitsRequest request) { SystemConfig.PerformanceConfig performanceConfig = globalSystemService.updateRateLimits( AdminSystemProtoMapper.fromUpdateRateLimits(request)); log.info("Rate limits updated by admin"); @@ -75,7 +78,7 @@ public ResponseEntity getPrompt() { } @PutMapping("/prompts") - public ResponseEntity updatePrompt(@RequestBody gg.modl.proto.modl.v1.UpdatePromptRequest request) { + public ResponseEntity updatePrompt(@RequestBody UpdatePromptRequest request) { SystemPrompt updated = globalSystemService.updatePrompt(AdminSystemProtoMapper.fromUpdatePrompt(request)); log.info("System prompt updated"); return ResponseEntity.ok(AdminSystemProtoMapper.toPromptResponse(updated, "System prompt updated successfully")); diff --git a/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java b/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java index 7bab645..ea4b766 100644 --- a/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java +++ b/src/main/java/gg/modl/backend/admin/controller/AdminSystemProtoMapper.java @@ -6,6 +6,8 @@ import gg.modl.backend.admin.dto.request.UpdatePromptRequest; import gg.modl.backend.admin.dto.request.UpdateRateLimitsRequest; import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest; +import gg.modl.backend.admin.dto.response.AdminMaintenanceStatus; +import gg.modl.backend.admin.dto.response.AdminRateLimitStatus; import gg.modl.proto.modl.v1.AdminSystemConfig; import gg.modl.proto.modl.v1.AdminSystemConfigResponse; import gg.modl.proto.modl.v1.AdminSystemFeaturesConfig; @@ -27,9 +29,7 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import java.util.Map; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.booleanValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -76,12 +76,11 @@ static AdminSystemConfigResponse toConfigResponse(SystemConfig config, String me return builder.build(); } - static AdminSystemMaintenanceResponse toMaintenanceResponse(Map status, String message) { + static AdminSystemMaintenanceResponse toMaintenanceResponse(AdminMaintenanceStatus status, String message) { AdminSystemMaintenanceStatus.Builder data = AdminSystemMaintenanceStatus.newBuilder() - .setIsActive(booleanValue(status.get("isActive"))); - Object messageValue = status.get("message"); - if (messageValue != null) { - data.setMessage(stringValue(messageValue)); + .setIsActive(status.active()); + if (status.message() != null) { + data.setMessage(status.message()); } AdminSystemMaintenanceResponse.Builder builder = AdminSystemMaintenanceResponse.newBuilder() .setSuccess(true) @@ -92,16 +91,14 @@ static AdminSystemMaintenanceResponse toMaintenanceResponse(Map return builder.build(); } - static AdminSystemRateLimitsResponse toRateLimitsResponse(Map status) { + static AdminSystemRateLimitsResponse toRateLimitsResponse(AdminRateLimitStatus status) { AdminSystemRateLimitsData.Builder data = AdminSystemRateLimitsData.newBuilder() - .setActive(booleanValue(status.get("active"))); - Object current = status.get("current"); - if (current instanceof SystemConfig.PerformanceConfig performance) { - data.setCurrent(toPerformance(performance)); + .setActive(status.active()); + if (status.current() != null) { + data.setCurrent(toPerformance(status.current())); } - Object resetTime = status.get("resetTime"); - if (resetTime != null) { - data.setResetTime(toTimestamp(resetTime)); + if (status.resetTime() != null) { + data.setResetTime(toTimestamp(status.resetTime())); } return AdminSystemRateLimitsResponse.newBuilder() .setSuccess(true) 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 1ea0219..f2c1ccf 100644 --- a/src/main/java/gg/modl/backend/admin/data/AdminUser.java +++ b/src/main/java/gg/modl/backend/admin/data/AdminUser.java @@ -7,11 +7,12 @@ import java.util.List; import lombok.Data; import org.springframework.data.annotation.Id; +import gg.modl.backend.database.CollectionName; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @Data -@Document(collection = "admin_users") +@Document(collection = CollectionName.ADMIN_USERS) @GenerateMongoFields public class AdminUser { @Id 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 62fa09f..88a8df3 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemConfig.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemConfig.java @@ -7,11 +7,12 @@ import java.util.List; import lombok.Data; import org.springframework.data.annotation.Id; +import gg.modl.backend.database.CollectionName; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @Data -@Document(collection = "system_config") +@Document(collection = CollectionName.SYSTEM_CONFIG) @GenerateMongoFields public class SystemConfig { @Id diff --git a/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java b/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java index ce413ff..9235ef1 100644 --- a/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java +++ b/src/main/java/gg/modl/backend/admin/data/SystemPrompt.java @@ -4,11 +4,12 @@ import java.util.Date; import lombok.Data; import org.springframework.data.annotation.Id; +import gg.modl.backend.database.CollectionName; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @Data -@Document(collection = "systemprompts") +@Document(collection = CollectionName.SYSTEM_PROMPTS) @GenerateMongoFields public class SystemPrompt { @Id diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java new file mode 100644 index 0000000..7cb737e --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsActivity.java @@ -0,0 +1,16 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.List; + +public record AdminAnalyticsActivity( + long totalPlayers, + long totalServers, + List data +) { + public record ActivityPoint( + String date, + long activeServers, + int onlinePlayers + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java new file mode 100644 index 0000000..7fb30f9 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsDashboard.java @@ -0,0 +1,60 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.server.data.Server; +import java.util.List; + +public record AdminAnalyticsDashboard( + Overview overview, + ServerMetrics serverMetrics, + UsageStatistics usageStatistics +) { + public record Overview( + long totalServers, + long activeServers, + long totalUsers, + long totalTickets, + String serverGrowthRate, + String userGrowthRate, + String avgPlayersPerServer, + String avgTicketsPerServer + ) { + } + + public record ServerMetrics( + List byPlan, + List byStatus, + List registrationTrend + ) { + } + + public record UsageStatistics( + List topServersByUsers, + List serverActivity, + List liveServers, + int totalPlayerCount, + List playerActivity + ) { + } + + public record ServerActivityPoint( + String date, + long activeServers + ) { + } + + public record LiveServer( + String serverId, + String serverName, + int playerCount, + String platform, + String version, + String pluginVersion + ) { + } + + public record PlayerActivityPoint( + String date, + int players + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java new file mode 100644 index 0000000..1243464 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsExport.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminAnalyticsExport( + String exportDate, + String range, + long servers, + long users, + long tickets +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java new file mode 100644 index 0000000..2c0e2fd --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsHistorical.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.List; + +public record AdminAnalyticsHistorical( + String metric, + String range, + List data +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java new file mode 100644 index 0000000..c0584b3 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminAnalyticsUsage.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminAnalyticsUsage( + long monthlyActiveServers, + long storage, + double storagePercent, + long apiCalls, + long databaseQueries +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java new file mode 100644 index 0000000..9f27d24 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminHistoricalPoint.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminHistoricalPoint( + String date, + long value +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java new file mode 100644 index 0000000..2f00c3e --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMaintenanceStatus.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminMaintenanceStatus( + boolean active, + String message +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java new file mode 100644 index 0000000..737ba5c --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringDashboard.java @@ -0,0 +1,50 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +public record AdminMonitoringDashboard( + ServerMetrics servers, + LogMetrics logs, + SystemHealth systemHealth, + List> trends, + Date lastUpdated +) { + public record ServerMetrics( + long total, + long active, + long pending, + long failed, + long recentRegistrations, + long concurrentServers, + long concurrentPlayers + ) { + } + + public record LogMetrics( + LogWindow last24h, + UnresolvedLogs unresolved + ) { + } + + public record LogWindow( + long total, + long critical, + long error, + long warning + ) { + } + + public record UnresolvedLogs( + long critical, + long error + ) { + } + + public record SystemHealth( + int score, + String status + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java new file mode 100644 index 0000000..0a36f32 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringHealth.java @@ -0,0 +1,31 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.Date; +import java.util.List; + +public record AdminMonitoringHealth( + String status, + List checks, + Date timestamp +) { + public record HealthCheck( + String name, + String status, + String message, + Long responseTime, + String error, + Long count + ) { + public static HealthCheck responsive(String name, String status, String message, long responseTime) { + return new HealthCheck(name, status, message, responseTime, null, null); + } + + public static HealthCheck failure(String name, String status, String message, String error) { + return new HealthCheck(name, status, message, null, error, null); + } + + public static HealthCheck counted(String name, String status, String message, long count) { + return new HealthCheck(name, status, message, null, null, count); + } + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java new file mode 100644 index 0000000..78ea12b --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringLogs.java @@ -0,0 +1,20 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.admin.data.SystemLog; +import java.util.List; + +public record AdminMonitoringLogs( + List logs, + AdminPagination pagination, + Filters filters +) { + public record Filters( + String level, + String source, + String serverId, + String category, + String resolved, + String search + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java new file mode 100644 index 0000000..8e8eb43 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminMonitoringSources.java @@ -0,0 +1,9 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.List; + +public record AdminMonitoringSources( + List sources, + List categories +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java new file mode 100644 index 0000000..7822eb5 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminNameCount.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminNameCount( + String name, + int value +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java new file mode 100644 index 0000000..e9a7500 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminPagination.java @@ -0,0 +1,9 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminPagination( + int page, + int limit, + long total, + int pages +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java new file mode 100644 index 0000000..9778bfb --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminRateLimitStatus.java @@ -0,0 +1,11 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.admin.data.SystemConfig; +import java.util.Date; + +public record AdminRateLimitStatus( + SystemConfig.PerformanceConfig current, + boolean active, + Date resetTime +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java new file mode 100644 index 0000000..c3e1c6a --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminRegistrationPoint.java @@ -0,0 +1,7 @@ +package gg.modl.backend.admin.dto.response; + +public record AdminRegistrationPoint( + String date, + int servers +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java new file mode 100644 index 0000000..d9ad6cb --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecurityEvents.java @@ -0,0 +1,10 @@ +package gg.modl.backend.admin.dto.response; + +import gg.modl.backend.admin.data.SecurityEvent; +import java.util.List; + +public record AdminSecurityEvents( + List events, + AdminPagination pagination +) { +} diff --git a/src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java new file mode 100644 index 0000000..369bb68 --- /dev/null +++ b/src/main/java/gg/modl/backend/admin/dto/response/AdminSecuritySummary.java @@ -0,0 +1,16 @@ +package gg.modl.backend.admin.dto.response; + +import java.util.Date; + +public record AdminSecuritySummary( + Last24Hours last24Hours, + long last7DaysTotal, + Date timestamp +) { + public record Last24Hours( + long critical, + long high, + long medium + ) { + } +} diff --git a/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java b/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java index cacf29a..b1aa1f9 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminAnalyticsService.java @@ -1,16 +1,28 @@ package gg.modl.backend.admin.service; +import gg.modl.backend.admin.dto.response.AdminAnalyticsActivity; +import gg.modl.backend.admin.dto.response.AdminAnalyticsDashboard; +import gg.modl.backend.admin.dto.response.AdminAnalyticsExport; +import gg.modl.backend.admin.dto.response.AdminAnalyticsHistorical; +import gg.modl.backend.admin.dto.response.AdminAnalyticsUsage; +import gg.modl.backend.admin.dto.response.AdminHistoricalPoint; +import gg.modl.backend.admin.dto.response.AdminNameCount; +import gg.modl.backend.admin.dto.response.AdminRegistrationPoint; import gg.modl.backend.analytics.data.MetricSnapshot; import gg.modl.backend.analytics.data.ServerInstanceSnapshot; import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; import gg.modl.backend.database.mongo.repository.MetricSnapshotMongoRepository; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.server.data.Server; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.DateServersResult; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.DateValueResult; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.NameValueResult; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; +import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.infrastructure.util.DateRangeUtil; +import gg.modl.backend.server.data.Server; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -19,28 +31,26 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateServersResult; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.DateValueResult; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.NameValueResult; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor @Slf4j public class AdminAnalyticsService { - private final ServerMongoRepository serverRepository; + private final ServerMetricsRepository serverMetricsRepository; + private final ServerUsageRepository serverUsageRepository; private final MetricSnapshotMongoRepository metricSnapshotRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; private final GlobalMongoAdminRepository globalMongoAdminRepository; private final AdminServerService adminServerService; - public Map getDashboard(String range) { + public AdminAnalyticsDashboard getDashboard(String range) { int days = DateRangeUtil.resolveRangeDays(range); Instant now = Instant.now(); Date startDate = Date.from(now.minus(days, ChronoUnit.DAYS)); Date previousStartDate = Date.from(now.minus(days * 2L, ChronoUnit.DAYS)); - ServerMongoRepository.DashboardStats stats = serverRepository.aggregateDashboardStats(startDate, previousStartDate); + ServerMetricsRepository.DashboardStats stats = serverMetricsRepository.aggregateDashboardStats(startDate, previousStartDate); long totalServers = stats.totalServers(); int refreshLimit = totalServers <= 200 ? (int) Math.max(totalServers, 1) : 50; @@ -56,10 +66,10 @@ public Map getDashboard(String range) { ? ((currentPeriodServers - previousPeriodServers) / (double) previousPeriodServers) * 100 : (currentPeriodServers > 0 ? 100 : 0); - List planResults = serverRepository.aggregatePlanCounts(); - List statusResults = serverRepository.aggregateProvisioningStatusCounts(); - List registrationTrend = serverRepository.findRegistrationTrend(startDate); - List topServers = serverRepository.findTopCompletedVerifiedByUserCount(10); + List planResults = serverMetricsRepository.aggregatePlanCounts(); + List statusResults = serverMetricsRepository.aggregateProvisioningStatusCounts(); + List registrationTrend = serverMetricsRepository.findRegistrationTrend(startDate); + List topServers = serverMetricsRepository.findTopCompletedVerifiedByUserCount(10); long serversWithData = stats.serversWithData(); double avgPlayersPerServer = serversWithData > 0 ? (double) totalUsers / serversWithData : 0; @@ -69,10 +79,9 @@ public Map getDashboard(String range) { Date last24h = Date.from(now.minus(24, ChronoUnit.HOURS)); List metricSnapshots = metricSnapshotRepository.findSinceOrdered(last24h); - List> serverActivity = metricSnapshots.stream().map(s -> Map.of( - "date", s.getDate().toInstant().toString(), - "activeServers", s.getActiveServers() - )).toList(); + List serverActivity = metricSnapshots.stream() + .map(s -> new AdminAnalyticsDashboard.ServerActivityPoint(s.getDate().toInstant().toString(), s.getActiveServers())) + .toList(); List instanceSnapshots = serverInstanceSnapshotRepository.findSinceOrdered(last24h); @@ -81,24 +90,23 @@ public Map getDashboard(String range) { ? instanceSnapshots.getLast() : null; - List> liveServers; + List liveServers; if (latestSnapshot != null && latestSnapshot.getServers() != null && !latestSnapshot.getServers().isEmpty()) { List serverIds = latestSnapshot.getServers().stream() .map(ServerInstanceSnapshot.ServerEntry::getServerId) .collect(Collectors.toList()); - Map serverNameMap = serverRepository.findUsageTargetsByIds(serverIds).stream() + Map serverNameMap = serverUsageRepository.findUsageTargetsByIds(serverIds).stream() .collect(Collectors.toMap(Server::getId, Server::getServerName, (a, b) -> a)); - liveServers = latestSnapshot.getServers().stream().map(srv -> { - Map map = new HashMap<>(); - map.put("serverId", srv.getServerId()); - map.put("serverName", serverNameMap.getOrDefault(srv.getServerId(), srv.getServerName())); - map.put("playerCount", srv.getPlayerCount()); - map.put("platform", srv.getPlatform()); - map.put("version", srv.getVersion()); - map.put("pluginVersion", srv.getPluginVersion()); - return map; - }).toList(); + liveServers = latestSnapshot.getServers().stream() + .map(srv -> new AdminAnalyticsDashboard.LiveServer( + srv.getServerId(), + serverNameMap.getOrDefault(srv.getServerId(), srv.getServerName()), + srv.getPlayerCount(), + srv.getPlatform(), + srv.getVersion(), + srv.getPluginVersion())) + .toList(); } else { liveServers = List.of(); } @@ -107,40 +115,33 @@ public Map getDashboard(String range) { ? sumPlayerCount(latestSnapshot) : 0; - List> playerActivity = instanceSnapshots.stream().map(s -> Map.of( - "date", s.getDate().toInstant().toString(), - "players", sumPlayerCount(s) - )).toList(); - - Map usageStatistics = new HashMap<>(); - usageStatistics.put("topServersByUsers", topServers); - usageStatistics.put("serverActivity", serverActivity); - usageStatistics.put("liveServers", liveServers); - usageStatistics.put("totalPlayerCount", totalPlayerCount); - usageStatistics.put("playerActivity", playerActivity); - - Map response = new HashMap<>(); - response.put("success", true); - response.put("data", Map.of( - "overview", Map.of( - "totalServers", totalServers, - "activeServers", activeServers, - "totalUsers", totalUsers, - "totalTickets", totalTickets, - "serverGrowthRate", String.format("%.2f", serverGrowthRate), - "userGrowthRate", "0.00", - "avgPlayersPerServer", String.format("%.1f", avgPlayersPerServer), - "avgTicketsPerServer", String.format("%.1f", avgTicketsPerServer) - ), - "serverMetrics", Map.of( - "byPlan", planResults, - "byStatus", statusResults, - "registrationTrend", registrationTrend - ), - "usageStatistics", usageStatistics, - "systemHealth", Map.of("errorRates", Collections.emptyList()) - )); - return response; + List playerActivity = instanceSnapshots.stream() + .map(s -> new AdminAnalyticsDashboard.PlayerActivityPoint(s.getDate().toInstant().toString(), sumPlayerCount(s))) + .toList(); + + AdminAnalyticsDashboard.Overview overview = new AdminAnalyticsDashboard.Overview( + totalServers, + activeServers, + totalUsers, + totalTickets, + String.format("%.2f", serverGrowthRate), + "0.00", + String.format("%.1f", avgPlayersPerServer), + String.format("%.1f", avgTicketsPerServer)); + + AdminAnalyticsDashboard.ServerMetrics serverMetrics = new AdminAnalyticsDashboard.ServerMetrics( + toNameCounts(planResults), + toNameCounts(statusResults), + toRegistrationPoints(registrationTrend)); + + AdminAnalyticsDashboard.UsageStatistics usageStatistics = new AdminAnalyticsDashboard.UsageStatistics( + topServers, + serverActivity, + liveServers, + totalPlayerCount, + playerActivity); + + return new AdminAnalyticsDashboard(overview, serverMetrics, usageStatistics); } private int sumPlayerCount(ServerInstanceSnapshot snapshot) { @@ -149,15 +150,15 @@ private int sumPlayerCount(ServerInstanceSnapshot snapshot) { : 0; } - public Map getActivity(String range) { + public AdminAnalyticsActivity getActivity(String range) { int days = DateRangeUtil.resolveRangeDays(range); Date startDate = Date.from(Instant.now().minus(days, ChronoUnit.DAYS)); List snapshots = metricSnapshotRepository.findSinceOrdered(startDate); List instanceSnapshots = serverInstanceSnapshotRepository.findSinceOrdered(startDate); - long totalServers = serverRepository.countAll(); - long totalPlayers = serverRepository.getUsageTotals().totalUsers(); + long totalServers = serverMetricsRepository.countAll(); + long totalPlayers = serverMetricsRepository.getUsageTotals().totalUsers(); Map playersByBucket = new HashMap<>(); for (ServerInstanceSnapshot inst : instanceSnapshots) { @@ -166,88 +167,64 @@ public Map getActivity(String range) { playersByBucket.merge(bucketKey, players, Math::max); } - List> activityData = snapshots.stream().map(s -> { + List activityData = snapshots.stream().map(s -> { String dateKey = s.getDate().toInstant().toString(); int onlinePlayers = playersByBucket.getOrDefault(dateKey, 0); - return Map.of( - "date", dateKey, - "activeServers", s.getActiveServers(), - "onlinePlayers", onlinePlayers - ); + return new AdminAnalyticsActivity.ActivityPoint(dateKey, s.getActiveServers(), onlinePlayers); }).toList(); - return Map.of("success", true, "data", activityData, - "totalPlayers", totalPlayers, "totalServers", totalServers); + return new AdminAnalyticsActivity(totalPlayers, totalServers, activityData); } - public Map getUsage() { + public AdminAnalyticsUsage getUsage() { Date thirtyDaysAgo = Date.from(Instant.now().minus(30, ChronoUnit.DAYS)); - long activeServers = serverRepository.countActiveSince(thirtyDaysAgo); + long activeServers = serverMetricsRepository.countActiveSince(thirtyDaysAgo); long storageSize = globalMongoAdminRepository.getStorageSize(); - return Map.of( - "success", true, - "data", Map.of( - "userEngagement", Map.of("monthlyActiveServers", activeServers), - "resourceUtilization", Map.of( - "storage", storageSize, - "storagePercent", 0.0, - "apiCalls", 0, - "databaseQueries", 0 - ) - ) - ); + return new AdminAnalyticsUsage(activeServers, storageSize, 0.0, 0L, 0L); } - public Map getHistorical(String metric, String range) { + public AdminAnalyticsHistorical getHistorical(String metric, String range) { if (metric == null || (!metric.equals("servers") && !metric.equals("users") && !metric.equals("tickets"))) { - return Map.of("success", false, "error", "Invalid metric type"); + throw new ValidationException("Invalid metric type"); } int days = DateRangeUtil.resolveRangeDays(range); Date startDate = Date.from(Instant.now().minus(days, ChronoUnit.DAYS)); - List results = serverRepository.aggregateHistoricalMetric(metric, startDate); - - return Map.of( - "success", true, - "data", Map.of( - "metric", metric, - "range", range != null && !range.isBlank() ? range : "30d", - "data", results - ) - ); + List results = serverMetricsRepository.aggregateHistoricalMetric(metric, startDate); + + return new AdminAnalyticsHistorical( + metric, + range != null && !range.isBlank() ? range : "30d", + results.stream().map(r -> new AdminHistoricalPoint(r.date(), r.value())).toList()); } - public Object exportAnalytics(String type, String range) { - String normalizedType = type != null ? type : "json"; + public String exportCsv(String range) { String normalizedRange = range != null && !range.isBlank() ? range : "30d"; - int days = DateRangeUtil.resolveRangeDays(normalizedRange); Date startDate = Date.from(Instant.now().minus(days, ChronoUnit.DAYS)); + return buildCsv(startDate); + } - if ("csv".equals(normalizedType)) { - return buildCsv(startDate); - } + public AdminAnalyticsExport exportJson(String range) { + String normalizedRange = range != null && !range.isBlank() ? range : "30d"; + + long totalServers = serverMetricsRepository.countAll(); + ServerMetricsRepository.UsageTotals totals = serverMetricsRepository.getUsageTotals(); - long totalServers = serverRepository.countAll(); - ServerMongoRepository.UsageTotals totals = serverRepository.getUsageTotals(); - - return Map.of( - "exportDate", new Date().toString(), - "range", normalizedRange, - "data", Map.of( - "servers", totalServers, - "users", totals.totalUsers(), - "tickets", totals.totalTickets() - ) - ); + return new AdminAnalyticsExport( + new Date().toString(), + normalizedRange, + totalServers, + totals.totalUsers(), + totals.totalTickets()); } private String buildCsv(Date startDate) { - List servers = serverRepository.aggregateHistoricalMetric("servers", startDate); - List users = serverRepository.aggregateHistoricalMetric("users", startDate); - List tickets = serverRepository.aggregateHistoricalMetric("tickets", startDate); + List servers = serverMetricsRepository.aggregateHistoricalMetric("servers", startDate); + List users = serverMetricsRepository.aggregateHistoricalMetric("users", startDate); + List tickets = serverMetricsRepository.aggregateHistoricalMetric("tickets", startDate); TreeMap byDate = new TreeMap<>(); for (DateValueResult r : servers) { @@ -270,4 +247,12 @@ private String buildCsv(Date startDate) { } return sb.toString(); } + + private static List toNameCounts(List results) { + return results.stream().map(r -> new AdminNameCount(r.name(), r.value())).toList(); + } + + private static List toRegistrationPoints(List results) { + return results.stream().map(r -> new AdminRegistrationPoint(r.date(), r.servers())).toList(); + } } diff --git a/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java b/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java index 4bfbb9f..eb679e0 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminAuthService.java @@ -13,6 +13,7 @@ import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -77,7 +78,7 @@ public Set extractSessionIds(HttpServletRequest request) { .filter(cookie -> RESTSecurityRole.ADMIN_SESSION_COOKIE.equals(cookie.getName())) .map(Cookie::getValue) .filter(value -> value != null && !value.isBlank()) - .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new)); + .collect(Collectors.toCollection(LinkedHashSet::new)); } public boolean isAdminSessionExpired(AuthSessionData session) { 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 5a96806..44836db 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminMonitoringService.java @@ -3,10 +3,15 @@ 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.admin.dto.response.AdminMonitoringDashboard; +import gg.modl.backend.admin.dto.response.AdminMonitoringHealth; +import gg.modl.backend.admin.dto.response.AdminMonitoringLogs; +import gg.modl.backend.admin.dto.response.AdminMonitoringSources; +import gg.modl.backend.admin.dto.response.AdminPagination; import gg.modl.backend.database.mongo.repository.GlobalMongoAdminRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository; import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository.MonitoringServerStats; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository.MonitoringServerStats; import gg.modl.backend.database.mongo.repository.SystemLogMongoRepository.MonitoringLogStats; import gg.modl.backend.server.data.ProvisioningStatus; import gg.modl.backend.infrastructure.util.CsvUtil; @@ -16,7 +21,6 @@ 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; @@ -30,75 +34,61 @@ @Slf4j public class AdminMonitoringService { private final SystemLogMongoRepository systemLogRepository; - private final ServerMongoRepository serverRepository; + private final ServerMetricsRepository serverMetricsRepository; private final GlobalMongoAdminRepository globalMongoAdminRepository; - public Map getDashboard() { + public AdminMonitoringDashboard 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); + MonitoringServerStats serverStats = serverMetricsRepository.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() - ); + int healthScore = calculateHealthScore(serverStats, logStats); 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() - ) - ); + List> trends = new ArrayList<>(systemLogRepository.findLogTrends(oneWeekAgo)); + + return new AdminMonitoringDashboard( + new AdminMonitoringDashboard.ServerMetrics( + serverStats.total(), + serverStats.active(), + serverStats.pending(), + serverStats.failed(), + serverStats.recentRegistrations(), + serverStats.concurrent(), + serverStats.concurrentPlayers()), + new AdminMonitoringDashboard.LogMetrics( + new AdminMonitoringDashboard.LogWindow( + logStats.total24h(), + logStats.critical24h(), + logStats.error24h(), + logStats.warning24h()), + new AdminMonitoringDashboard.UnresolvedLogs( + logStats.unresolvedCritical(), + logStats.unresolvedError())), + new AdminMonitoringDashboard.SystemHealth(healthScore, healthStatus), + trends, + new Date()); } - private int calculateHealthScore(long total, long active, long failed, long critical, long errors, long unresolvedCritical, long unresolvedErrors) { + private int calculateHealthScore(MonitoringServerStats serverStats, MonitoringLogStats logStats) { int score = 100; - if (total > 0) { - score -= (int) ((failed / (double) total) * 30); + if (serverStats.total() > 0) { + score -= (int) ((serverStats.failed() / (double) serverStats.total()) * 30); } - score -= (int) Math.min(critical * 5, 25); - score -= (int) Math.min(errors, 20); - score -= (int) (unresolvedCritical * 10); - score -= (int) (unresolvedErrors * 3); + score -= (int) Math.min(logStats.critical24h() * 5, 25); + score -= (int) Math.min(logStats.error24h(), 20); + score -= (int) (logStats.unresolvedCritical() * 10); + score -= (int) (logStats.unresolvedError() * 3); return Math.max(0, score); } - public Map getLogs( + public AdminMonitoringLogs getLogs( int page, int limit, String level, @@ -134,27 +124,10 @@ public Map getLogs( ); 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 - ) - ); + return new AdminMonitoringLogs( + logs, + new AdminPagination(pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum)), + new AdminMonitoringLogs.Filters(level, source, serverId, category, resolved, search)); } public SystemLog createLog(CreateSystemLogRequest request) { @@ -163,20 +136,14 @@ public SystemLog createLog(CreateSystemLogRequest request) { return systemLogRepository.saveEntity(logData); } - public Map getSources() { + public AdminMonitoringSources 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 - ) - ); + return new AdminMonitoringSources(sources, categories); } public Optional resolveLog(String id, ResolveLogRequest request) { @@ -187,27 +154,25 @@ public Optional resolveLog(String id, ResolveLogRequest request) { )); } - public Map getHealth() { - List> checks = new ArrayList<>(); + public AdminMonitoringHealth 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 - )); + checks.add(AdminMonitoringHealth.HealthCheck.responsive( + "Database Connectivity", + "healthy", + "MongoDB connection is responsive.", + responseTime)); } catch (Exception exception) { - checks.add(Map.of( - "name", "Database Connectivity", - "status", "critical", - "message", "Failed to ping MongoDB.", - "error", exception.getMessage() - )); + checks.add(AdminMonitoringHealth.HealthCheck.failure( + "Database Connectivity", + "critical", + "Failed to ping MongoDB.", + exception.getMessage())); overallStatus = "critical"; } @@ -216,38 +181,29 @@ public Map getHealth() { 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 - )); + checks.add(AdminMonitoringHealth.HealthCheck.counted( + "Critical System Logs", + logStatus, + criticalCount + " unresolved critical log(s) in the last 24 hours.", + criticalCount)); if ("critical".equals(logStatus)) { overallStatus = "critical"; } else if ("degraded".equals(logStatus) && !"critical".equals(overallStatus)) { overallStatus = "degraded"; } - long failedCount = serverRepository.countByProvisioningStatus(ProvisioningStatus.FAILED); + long failedCount = serverMetricsRepository.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 - )); + checks.add(AdminMonitoringHealth.HealthCheck.counted( + "Server Provisioning", + serverStatus, + failedCount + " server(s) failed to provision.", + 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() - ) - ); + return new AdminMonitoringHealth(overallStatus, checks, new Date()); } public long deleteLogs(List logIds) { 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 159df9d..e16149e 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminSecurityService.java @@ -1,6 +1,9 @@ package gg.modl.backend.admin.service; import gg.modl.backend.admin.data.SecurityEvent; +import gg.modl.backend.admin.dto.response.AdminPagination; +import gg.modl.backend.admin.dto.response.AdminSecurityEvents; +import gg.modl.backend.admin.dto.response.AdminSecuritySummary; import gg.modl.backend.database.mongo.repository.SecurityEventMongoRepository; import gg.modl.backend.infrastructure.util.DateRangeUtil; import gg.modl.backend.infrastructure.util.PaginationHelper; @@ -8,7 +11,6 @@ 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; @@ -17,7 +19,7 @@ public class AdminSecurityService { private final SecurityEventMongoRepository securityEventRepository; - public Map getSecurityEvents( + public AdminSecurityEvents getSecurityEvents( int page, int limit, String type, @@ -36,21 +38,12 @@ public Map getSecurityEvents( 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) - ) - ) - ); + return new AdminSecurityEvents( + events, + new AdminPagination(pageNum, limitNum, total, PaginationHelper.calculateTotalPages(total, limitNum))); } - public Map getSecuritySummary() { + public AdminSecuritySummary getSecuritySummary() { Date last24h = Date.from(Instant.now().minus(24, ChronoUnit.HOURS)); Date last7d = Date.from(Instant.now().minus(7, ChronoUnit.DAYS)); @@ -59,17 +52,9 @@ public Map getSecuritySummary() { 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() - ) - ); + return new AdminSecuritySummary( + new AdminSecuritySummary.Last24Hours(criticalEvents24h, highEvents24h, mediumEvents24h), + totalEvents7d, + 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 63dffaa..efe7458 100644 --- a/src/main/java/gg/modl/backend/admin/service/AdminServerService.java +++ b/src/main/java/gg/modl/backend/admin/service/AdminServerService.java @@ -1,15 +1,21 @@ package gg.modl.backend.admin.service; +import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.database.mongo.repository.ServerAdminRepository; import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerProvisioningRepository; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; 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.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 gg.modl.backend.staff.service.StaffService; +import java.time.Instant; import java.util.Date; import java.util.HashMap; import java.util.List; @@ -19,6 +25,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.mongodb.core.query.Update; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -26,7 +33,9 @@ @RequiredArgsConstructor @Slf4j public class AdminServerService { - private final ServerMongoRepository serverRepository; + private final ServerAdminRepository serverAdminRepository; + private final ServerProvisioningRepository serverProvisioningRepository; + private final ServerUsageRepository serverUsageRepository; private final ServerDatabaseMongoRepository serverDatabaseRepository; private final ServerProvisioningService provisioningService; private final ServerService serverService; @@ -35,7 +44,7 @@ public class AdminServerService { private static final int MAX_USAGE_BATCH_SIZE = 50; public long countServers(String search, String plan, String status) { - return serverRepository.countAdminServers(search, plan, status); + return serverAdminRepository.countAdminServers(search, plan, status); } @Async @@ -44,7 +53,7 @@ public void refreshUsageStatsForActiveServers(int maxServers) { Date now = new Date(); Date staleCutoff = new Date(now.getTime() - USAGE_STATS_TTL_MILLIS); - List servers = serverRepository.findUsageRefreshCandidates(staleCutoff, boundedLimit); + List servers = serverUsageRepository.findUsageRefreshCandidates(staleCutoff, boundedLimit); for (Server server : servers) { getOrComputeUsageStats(server, now, false); } @@ -84,7 +93,7 @@ private boolean isUsageStatsCacheFresh(Server server, Date now) { } private void persistUsageStats(String serverId, long userCount, long ticketCount, Date updatedAt) { - serverRepository.updateUsageStats(serverId, userCount, ticketCount, updatedAt); + serverUsageRepository.updateUsageStats(serverId, userCount, ticketCount, updatedAt); } public Map getUsageStatsForServerIds(List serverIds, boolean forceRefresh) { @@ -105,7 +114,7 @@ public Map getUsageStatsForServerIds(List serverId } Date now = new Date(); - List servers = serverRepository.findUsageTargetsByIds(filteredIds); + List servers = serverUsageRepository.findUsageTargetsByIds(filteredIds); Map usageByServerId = new HashMap<>(); for (Server server : servers) { @@ -155,49 +164,106 @@ public String exportServersCsv(String plan, String status) { } 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); + return serverAdminRepository.findAdminServers(search, plan, status, sortField, sortOrder, skip, limit); } public Optional findById(String id) { - return serverRepository.findById(id); + return serverAdminRepository.findById(id); } - public Server save(Server server) { - Server saved = serverRepository.saveEntity(server); - serverService.evictAllServerCaches(); - return saved; + public Server updateById(String id, Map updateData) { + Update update = buildAllowedFieldsUpdate(updateData); + return serverAdminRepository.applyFieldUpdate(id, update).orElse(null); } - public Server updateById(String id, Map updateData) { - return serverRepository.updateAllowedFields(id, updateData).orElse(null); + private Update buildAllowedFieldsUpdate(Map updateData) { + Update update = new Update(); + 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); + case ServerFields.EMAIL_VERIFIED -> update.set(ServerFields.EMAIL_VERIFIED, value); + case ServerFields.PROVISIONING_STATUS -> update.set(ServerFields.PROVISIONING_STATUS, normalizeProvisioningStatus(value)); + case ServerFields.PROVISIONING_NOTES -> update.set(ServerFields.PROVISIONING_NOTES, value); + case ServerFields.PLAN -> update.set(ServerFields.PLAN, normalizePlan(value)); + case ServerFields.SUBSCRIPTION_STATUS -> update.set(ServerFields.SUBSCRIPTION_STATUS, normalizeSubscriptionStatus(value)); + case ServerFields.LAST_ACTIVITY_AT -> update.set(ServerFields.LAST_ACTIVITY_AT, normalizeDate(value)); + case ServerFields.UPDATED_AT -> update.set(ServerFields.UPDATED_AT, normalizeDate(value)); + default -> { + } + } + } + return update; + } + + 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 deleteById(String id) { - return serverRepository.deleteByServerId(id); + return serverAdminRepository.deleteByServerId(id); } public long bulkDelete(List serverIds) { - return serverRepository.deleteByServerIds(serverIds); + return serverAdminRepository.deleteByServerIds(serverIds); } public long bulkSuspend(List serverIds) { - return serverRepository.bulkSuspend(serverIds, new Date()); + return serverAdminRepository.bulkSuspend(serverIds, new Date()); } public long bulkActivate(List serverIds) { - long modified = serverRepository.bulkActivate(serverIds, new Date()); + long modified = serverAdminRepository.bulkActivate(serverIds, new Date()); - List servers = serverRepository.findProvisioningCandidatesByIds(serverIds); + List servers = serverProvisioningRepository.findProvisioningCandidatesByIds(serverIds); for (Server server : servers) { if (server.getDatabaseName() == null) { continue; } try { provisioningService.provision(server); - serverRepository.markProvisioningCompleted(server.getId()); + serverProvisioningRepository.markProvisioningCompleted(server.getId()); } catch (Exception e) { log.warn("Failed to provision server {}", server.getId(), e); - serverRepository.markProvisioningFailed(server.getId(), "Admin reprovision failed."); + serverProvisioningRepository.markProvisioningFailed(server.getId(), Server.boundProvisioningNotes("Admin reprovision failed.")); } } @@ -208,7 +274,7 @@ public long bulkActivate(List serverIds) { public long bulkUpdatePlan(List serverIds, String plan) { ServerPlan parsedPlan = ServerPlan.valueOf(plan.trim().toUpperCase(Locale.ROOT)); - return serverRepository.bulkUpdatePlan(serverIds, parsedPlan, new Date()); + return serverAdminRepository.bulkUpdatePlan(serverIds, parsedPlan, new Date()); } public Map getServerStats(Server server) { @@ -216,13 +282,7 @@ public Map getServerStats(Server server) { 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); + putCachedStats(stats, server); return stats; } @@ -240,6 +300,11 @@ public Map getServerStats(Server server) { } log.warn("Failed to get stats for server {}", server.getServerName()); + putCachedStats(stats, server); + return stats; + } + + private void putCachedStats(Map stats, Server server) { long cachedUsers = server.getUserCount() != null ? server.getUserCount() : 0L; long cachedTickets = server.getTicketCount() != null ? server.getTicketCount() : 0L; stats.put("totalPlayers", cachedUsers); @@ -247,7 +312,6 @@ public Map getServerStats(Server server) { stats.put("totalLogs", 0); stats.put("lastActivity", server.getUpdatedAt()); stats.put("databaseSize", 0); - return stats; } public void resetServerDatabase(Server server) { @@ -259,7 +323,7 @@ public void resetServerDatabase(Server server) { } } - serverRepository.resetAfterDatabaseDrop(server.getId(), new Date()); + serverProvisioningRepository.resetAfterDatabaseDrop(server.getId(), new Date()); } private record ComputedUsage(long userCount, long ticketCount, Date updatedAt, boolean fromCache) {} diff --git a/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java b/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java index 9a5ba05..e053c57 100644 --- a/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java +++ b/src/main/java/gg/modl/backend/admin/service/GlobalSystemService.java @@ -6,11 +6,12 @@ import gg.modl.backend.admin.dto.request.UpdatePromptRequest; import gg.modl.backend.admin.dto.request.UpdateRateLimitsRequest; import gg.modl.backend.admin.dto.request.UpdateSystemConfigRequest; +import gg.modl.backend.admin.dto.response.AdminMaintenanceStatus; +import gg.modl.backend.admin.dto.response.AdminRateLimitStatus; import gg.modl.backend.ai.service.AITicketAnalysisService; import gg.modl.backend.database.mongo.repository.SystemConfigMongoRepository; import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; import java.util.Date; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -38,15 +39,14 @@ public SystemConfig getOrCreateConfig() { .orElseGet(() -> systemConfigRepository.saveEntity(new SystemConfig())); } - public Map getMaintenanceStatus() { + public AdminMaintenanceStatus getMaintenanceStatus() { SystemConfig config = getOrCreateConfig(); - return Map.of( - "isActive", config.getGeneral().isMaintenanceMode(), - "message", config.getGeneral().getMaintenanceMessage() - ); + return new AdminMaintenanceStatus( + config.getGeneral().isMaintenanceMode(), + config.getGeneral().getMaintenanceMessage()); } - public Map toggleMaintenance(ToggleMaintenanceRequest request) { + public AdminMaintenanceStatus toggleMaintenance(ToggleMaintenanceRequest request) { SystemConfig config = getOrCreateConfig(); config.getGeneral().setMaintenanceMode(request.enabled()); if (request.message() != null) { @@ -54,19 +54,17 @@ public Map toggleMaintenance(ToggleMaintenanceRequest request) { } config.setUpdatedAt(new Date()); SystemConfig saved = systemConfigRepository.saveEntity(config); - return Map.of( - "isActive", saved.getGeneral().isMaintenanceMode(), - "message", saved.getGeneral().getMaintenanceMessage() - ); + return new AdminMaintenanceStatus( + saved.getGeneral().isMaintenanceMode(), + saved.getGeneral().getMaintenanceMessage()); } - public Map getRateLimitStatus() { + public AdminRateLimitStatus getRateLimitStatus() { SystemConfig config = getOrCreateConfig(); - return Map.of( - "current", config.getPerformance(), - "active", true, - "resetTime", new Date(System.currentTimeMillis() + 15 * 60 * 1000) - ); + return new AdminRateLimitStatus( + config.getPerformance(), + true, + new Date(System.currentTimeMillis() + 15 * 60 * 1000)); } public SystemConfig.PerformanceConfig updateRateLimits(UpdateRateLimitsRequest request) { 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 16878d4..3cd2e17 100644 --- a/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java +++ b/src/main/java/gg/modl/backend/ai/external/GeminiLLMProvider.java @@ -16,6 +16,7 @@ public class GeminiLLMProvider implements LLMProvider { private static final ObjectMapper MAPPER = new ObjectMapper(); private final Client client; + private final boolean connected; private final String geminiModelId; private final Schema responseSchema; private final float temperature; @@ -23,9 +24,11 @@ public class GeminiLLMProvider implements LLMProvider { private final int maxOutputTokens; public GeminiLLMProvider(LLMConfiguration config) { + String apiKey = config.getGeminiApiKey(); this.client = Client.builder() - .apiKey(config.getGeminiApiKey()) + .apiKey(apiKey) .build(); + this.connected = apiKey != null && !apiKey.isBlank(); this.responseSchema = parseSchema(DefaultPrompts.JSON_FORMAT); this.temperature = config.getGeminiTemperature(); this.topP = config.getGeminiTopP(); @@ -59,6 +62,6 @@ private static Schema parseSchema(String jsonSchema) { @Override public boolean isConnected() { - return client != null; + return connected; } } 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 c97b4d7..0011f28 100644 --- a/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java +++ b/src/main/java/gg/modl/backend/ai/service/AITicketAnalysisService.java @@ -1,14 +1,11 @@ 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.ServerUsageRepository; import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.database.mongo.repository.SystemPromptMongoRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; @@ -27,15 +24,11 @@ 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; @@ -50,19 +43,18 @@ public class AITicketAnalysisService { private final LLMService llmService; private final AIModerationSettingsService aiModerationSettingsService; private final TicketMongoRepository ticketRepository; - private final ServerMongoRepository serverRepository; + private final ServerUsageRepository serverUsageRepository; private final PunishmentLifecycleService punishmentLifecycleService; private final PunishmentTypeService punishmentTypeService; private final UsageTrackingService usageTrackingService; private final ServerLimitPolicy serverLimitPolicy; - private final ObjectMapper objectMapper; + private final ChatModerationPromptBuilder promptBuilder; + private final AiAnalysisResponseParser responseParser; private final SystemPromptMongoRepository systemPromptRepository; private final StaffMongoRepository staffRepository; - public static final String AI_MODERATOR = "AI Moderator"; + private 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) { @@ -89,7 +81,7 @@ public void analyzeTicketAsync(@NotNull Server server, @NotNull String ticketId) return; } - final ModerationPrompt prompt = buildModerationPrompt(ticket, settings); + final ChatModerationPromptBuilder.ModerationPrompt prompt = promptBuilder.buildModerationPrompt(ticket, settings, this::getSystemPrompt); if (prompt == null) { return; } @@ -101,7 +93,7 @@ public void analyzeTicketAsync(@NotNull Server server, @NotNull String ticketId) log.error("LLM generation failed for ticket {}", ticketId, e); return; } - final AIAnalysisResult result = parseResponse(rawResponse); + final AIAnalysisResult result = responseParser.parseResponse(rawResponse); if (result == null) { return; } @@ -135,7 +127,7 @@ private AIModerationSettings resolveActiveModerationSettings(@NotNull Server ser return null; } - final ServerMongoRepository.AIUsageSnapshot usageSnapshot = serverRepository.findAIUsageSnapshotById(server.getId()).orElse(null); + final ServerUsageRepository.AIUsageSnapshot usageSnapshot = serverUsageRepository.findAIUsageSnapshotById(server.getId()).orElse(null); if (usageSnapshot != null) { long currentUsage = usageSnapshot.aiRequestsCurrentPeriod(); long limit = limits.getAiRequestLimit(); @@ -283,165 +275,6 @@ 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); @@ -507,6 +340,4 @@ public AISuggestionResult dismissAISuggestion(@NotNull Server server, @NotNull S } public record AISuggestionResult(boolean success, String error) {} - - private record ModerationPrompt(String systemInstruction, String userContent) {} } diff --git a/src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java b/src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java new file mode 100644 index 0000000..7bac5f5 --- /dev/null +++ b/src/main/java/gg/modl/backend/ai/service/AiAnalysisResponseParser.java @@ -0,0 +1,89 @@ +package gg.modl.backend.ai.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.ai.data.AIAnalysisResult; +import java.util.Date; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AiAnalysisResponseParser { + private final ObjectMapper objectMapper; + + @Nullable + public 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 = parseNumber(actionNode, "punishmentTypeId", JsonNode::asInt, Integer::parseInt); + 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(parseNumber(json, "confidence", JsonNode::asDouble, Double::parseDouble)); + return result; + } catch (Exception e) { + log.error("Failed to parse AI response: {}", rawResponse, e); + return null; + } + } + + @Nullable + private T parseNumber(@Nullable JsonNode node, @Nullable String field, + @NotNull Function fromNumber, + @NotNull Function fromText) { + if (node == null || field == null || !node.has(field) || node.get(field).isNull()) { + return null; + } + + final JsonNode value = node.get(field); + if (value.isNumber()) { + return fromNumber.apply(value); + } + + if (value.isTextual()) { + try { + return fromText.apply(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; + } +} diff --git a/src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java b/src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java new file mode 100644 index 0000000..15c3740 --- /dev/null +++ b/src/main/java/gg/modl/backend/ai/service/ChatModerationPromptBuilder.java @@ -0,0 +1,101 @@ +package gg.modl.backend.ai.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import gg.modl.backend.ai.data.DefaultPrompts; +import gg.modl.backend.settings.data.AIModerationSettings; +import gg.modl.backend.settings.data.AIModerationSettings.AIPunishmentConfig; +import gg.modl.backend.ticket.data.Ticket; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; +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.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ChatModerationPromptBuilder { + private static final String REPORTED_PLAYER_REFERENCE = "the reported player identified in the untrusted chat data"; + private static final SecureRandom NONCE_RANDOM = new SecureRandom(); + + private final ObjectMapper objectMapper; + + @Nullable + public ModerationPrompt buildModerationPrompt(@NotNull Ticket ticket, @NotNull AIModerationSettings settings, @NotNull Supplier systemPrompt) { + 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 = systemPrompt.get() + .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")); + } + + public record ModerationPrompt(String systemInstruction, String userContent) {} +} diff --git a/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java b/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java index 0c03815..51f2754 100644 --- a/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java +++ b/src/main/java/gg/modl/backend/alert/controller/AdminSystemAlertController.java @@ -3,16 +3,16 @@ import gg.modl.backend.alert.data.SystemAlert; import gg.modl.backend.alert.service.SystemAlertService; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.infrastructure.filter.AdminAuthFilter; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.proto.modl.v1.AdminSystemAlertResponse; import gg.modl.proto.modl.v1.AdminSystemAlertsResponse; import gg.modl.proto.modl.v1.CreateSystemAlertRequest; import gg.modl.proto.modl.v1.UpdateSystemAlertRequest; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -33,13 +33,16 @@ public ResponseEntity getAlerts() { } @PostMapping - public ResponseEntity createAlert(@RequestBody CreateSystemAlertRequest request) { + public ResponseEntity createAlert( + @RequestBody CreateSystemAlertRequest request, + HttpServletRequest httpRequest + ) { SystemAlert alert = alertService.createAlert( request.getMessage(), AlertProtoMapper.parseSeverity(request.getSeverity()), AlertProtoMapper.parseAudience(request.getAudience()), AlertProtoMapper.toExpiresAt(request.getExpiresAt()), - getAdminEmail() + getAdminEmail(httpRequest) ); return ResponseEntity.status(HttpStatus.CREATED).body(AlertProtoMapper.toAdminAlert(alert)); } @@ -47,7 +50,8 @@ public ResponseEntity createAlert(@RequestBody CreateS @PutMapping("/{id}") public ResponseEntity updateAlert( @PathVariable String id, - @RequestBody UpdateSystemAlertRequest request + @RequestBody UpdateSystemAlertRequest request, + HttpServletRequest httpRequest ) { SystemAlert alert = alertService.updateAlert( id, @@ -56,16 +60,14 @@ public ResponseEntity updateAlert( request.hasAudience() ? AlertProtoMapper.parseAudienceStrict(request.getAudience()) : null, request.hasExpiresAt(), request.hasExpiresAt() ? AlertProtoMapper.toExpiresAt(request.getExpiresAt()) : null, - getAdminEmail() + getAdminEmail(httpRequest) ).orElseThrow(() -> new ResourceNotFoundException("Alert not found")); return ResponseEntity.ok(AlertProtoMapper.toAdminAlert(alert)); } - private String getAdminEmail() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null || authentication.getName() == null || authentication.getName().isBlank()) { - return "unknown"; - } - return authentication.getName(); + private String getAdminEmail(HttpServletRequest request) { + return AdminAuthFilter.actingEmail(request) + .filter(email -> !email.isBlank()) + .orElse("unknown"); } } diff --git a/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java b/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java index 50acaf1..34f2ee4 100644 --- a/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java +++ b/src/main/java/gg/modl/backend/alert/controller/PanelSystemAlertController.java @@ -1,6 +1,8 @@ package gg.modl.backend.alert.controller; import gg.modl.backend.alert.service.SystemAlertService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.role.service.PermissionService; @@ -22,6 +24,7 @@ public class PanelSystemAlertController { private final PermissionService permissionService; @GetMapping("/alerts") + @RequiresPanelPermission(rule = PanelAccessRule.PERMIT_ALL) public ResponseEntity getAlerts(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); String email = RequestUtil.getSessionEmail(request); 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 1367e91..4dec46b 100644 --- a/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java +++ b/src/main/java/gg/modl/backend/analytics/controller/AnalyticsController.java @@ -6,6 +6,7 @@ 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.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -20,6 +21,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_ANALYTICS) +@RequiresPanelPermission("admin.audit.view.analytics") @RequiredArgsConstructor public class AnalyticsController { private final AnalyticsService analyticsService; diff --git a/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java b/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java index 90cf83e..ab142ab 100644 --- a/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java +++ b/src/main/java/gg/modl/backend/analytics/data/MetricSnapshot.java @@ -16,7 +16,7 @@ public class MetricSnapshot { private String id; @Field - private Date date; // truncated to hour (UTC) + private Date date; @Field private long activeServers; 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 eeb8dde..c0cd8fc 100644 --- a/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java +++ b/src/main/java/gg/modl/backend/analytics/service/AnalyticsService.java @@ -1,7 +1,5 @@ package gg.modl.backend.analytics.service; -import static gg.modl.backend.infrastructure.util.SafeConvertUtil.toInt; - import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import gg.modl.backend.analytics.dto.response.AuditLogsAnalyticsResponse; @@ -11,6 +9,8 @@ import gg.modl.backend.analytics.dto.response.TicketAnalyticsResponse; import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository; import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository.IdCountResult; +import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository.PlayerActivityFacet; +import gg.modl.backend.database.mongo.repository.AnalyticsMongoRepository.PunishmentAnalyticsFacet; import gg.modl.backend.player.service.IssuerNameResolver; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.service.PunishmentTypeIndex; @@ -18,6 +18,7 @@ import gg.modl.backend.staff.service.StaffService; import gg.modl.backend.ticket.data.TicketCategory; import gg.modl.backend.ticket.data.TicketStatus; +import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.infrastructure.util.DateRangeUtil; import java.time.Duration; import java.time.Instant; @@ -25,7 +26,6 @@ import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; @@ -33,7 +33,6 @@ import java.util.List; import java.util.Map; import java.util.Set; -import java.util.TimeZone; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.bson.Document; @@ -44,7 +43,8 @@ @RequiredArgsConstructor @Slf4j public class AnalyticsService { - private static final String ANALYTICS_TIME_ZONE = TimeZone.getDefault().getID(); + private static final String ANALYTICS_TIME_ZONE = "UTC"; + private static final double MILLIS_PER_HOUR = 1000.0 * 60 * 60; private final AnalyticsMongoRepository analyticsRepository; private final PunishmentTypeService punishmentTypeService; @@ -81,7 +81,9 @@ private OverviewResponse computeOverview(Server server) { final int ticketChange = stats.previousTickets() > 0 ? (int) Math.round(((double) (stats.recentTickets() - stats.previousTickets()) / stats.previousTickets()) * 100) : 0; - final int playerChange = 0; // TODO: implement or remove + final int playerChange = stats.previousPlayers() > 0 + ? (int) Math.round(((double) (stats.recentPlayers() - stats.previousPlayers()) / stats.previousPlayers()) * 100) + : 0; return new OverviewResponse( stats.totalTickets(), @@ -115,7 +117,12 @@ private TicketAnalyticsResponse computeTicketAnalytics(Server server, String per .map(result -> new TicketAnalyticsResponse.DailyTicket(result.id(), result.count())) .toList(); - final List avgResolution = Collections.emptyList(); + final List avgResolution = + analyticsRepository.aggregateAvgResolutionByCategory(server, startDate).stream() + .map(result -> new TicketAnalyticsResponse.CategoryResolutionTime( + normalizeCategory(result.id()), + result.avgMillis() / MILLIS_PER_HOUR)) + .toList(); return new TicketAnalyticsResponse(byStatus, byCategory, avgResolution, dailyTickets); } @@ -136,51 +143,47 @@ private String normalizeStatus(String status) { } } - public PunishmentAnalyticsResponse getPunishmentAnalytics(Server server, String period) { + @NotNull + public PunishmentAnalyticsResponse getPunishmentAnalytics(@NotNull Server server, @NotNull String period) { return punishmentAnalyticsCache.get(cacheKey(server, period), key -> computePunishmentAnalytics(server, period)); } private PunishmentAnalyticsResponse computePunishmentAnalytics(Server server, String period) { Date startDate = DateRangeUtil.getStartDate(period); - Document facetResults = analyticsRepository.aggregatePunishmentAnalytics(server, startDate, ANALYTICS_TIME_ZONE); - if (facetResults == null) { + PunishmentAnalyticsFacet facet = analyticsRepository.aggregatePunishmentAnalytics(server, startDate, ANALYTICS_TIME_ZONE); + if (facet == null) { return new PunishmentAnalyticsResponse(List.of(), List.of(), List.of()); } Map punishmentTypeNames = resolvePunishmentTypeNames(server); - List byType = toDocumentList(facetResults.get("byType")).stream() - .map(doc -> { - Object rawTypeOrdinal = doc.get("_id"); - Integer typeOrdinal = rawTypeOrdinal instanceof Number number ? number.intValue() : null; + List byType = facet.byType().stream() + .map(entry -> { + Integer typeOrdinal = entry.typeOrdinal(); String typeName = typeOrdinal != null ? punishmentTypeNames.getOrDefault(typeOrdinal, "Unknown") : "Unknown"; - return new PunishmentAnalyticsResponse.TypeCount(typeName, toInt(doc.get("count"))); + return new PunishmentAnalyticsResponse.TypeCount(typeName, entry.count()); }) .sorted((a, b) -> Integer.compare(b.count(), a.count())) .toList(); - // Collect potential staff IDs for batch resolution - List byStaffDocs = toDocumentList(facetResults.get("byStaff")); Set potentialIds = new HashSet<>(); - for (Document document : byStaffDocs) { - Object rawId = document.get("_id"); - if (rawId instanceof String s && !s.isBlank()) { - potentialIds.add(s); + for (var entry : facet.byStaff()) { + if (entry.issuerId() != null && !entry.issuerId().isBlank()) { + potentialIds.add(entry.issuerId()); } } Map resolvedStaff = issuerNameResolver.batchResolve(potentialIds, server); Map staffCountMap = new HashMap<>(); - for (Document document : byStaffDocs) { - Object rawId = document.get("_id"); + for (var entry : facet.byStaff()) { String staffName; - if (rawId instanceof String s && resolvedStaff.containsKey(s)) { - staffName = resolvedStaff.get(s); + if (entry.issuerId() != null && resolvedStaff.containsKey(entry.issuerId())) { + staffName = resolvedStaff.get(entry.issuerId()); } else { - staffName = normalizeStaffName(rawId); + staffName = normalizeStaffName(entry.issuerId()); } - staffCountMap.merge(staffName, toInt(document.get("count")), Integer::sum); + staffCountMap.merge(staffName, entry.count(), Integer::sum); } List byStaff = staffCountMap.entrySet() .stream() @@ -190,9 +193,8 @@ private PunishmentAnalyticsResponse computePunishmentAnalytics(Server server, St .toList(); Map dailyPunishmentMap = new LinkedHashMap<>(); - for (Document document : toDocumentList(facetResults.get("daily"))) { - String dayLabel = document.getString("_id"); - dailyPunishmentMap.merge(dayLabel, toInt(document.get("count")), Integer::sum); + for (var entry : facet.daily()) { + dailyPunishmentMap.merge(entry.date(), entry.count(), Integer::sum); } List dailyPunishments = dailyPunishmentMap.entrySet() .stream() @@ -209,20 +211,6 @@ private Map resolvePunishmentTypeNames(Server server) { return typeNames; } - private List toDocumentList(Object value) { - if (!(value instanceof List rawList)) { - return List.of(); - } - - List documents = new ArrayList<>(); - for (Object item : rawList) { - if (item instanceof Document document) { - documents.add(document); - } - } - return documents; - } - private String normalizeStaffName(Object rawStaffName) { if (rawStaffName == null) { return "Unknown"; @@ -232,7 +220,8 @@ private String normalizeStaffName(Object rawStaffName) { return normalized.isBlank() ? "Unknown" : normalized; } - public AuditLogsAnalyticsResponse getAuditLogsAnalytics(Server server, String period) { + @NotNull + public AuditLogsAnalyticsResponse getAuditLogsAnalytics(@NotNull Server server, @NotNull String period) { return auditLogsAnalyticsCache.get(cacheKey(server, period), key -> computeAuditLogsAnalytics(server, period)); } @@ -257,7 +246,7 @@ private AuditLogsAnalyticsResponse computeAuditLogsAnalytics(Server server, Stri if (bucketKey == null) { continue; } - hourlyMap.merge(bucketKey, toInt(doc.get("count")), Integer::sum); + hourlyMap.merge(bucketKey, ProtoMapperSupport.intValueOrZero(doc.get("count")), Integer::sum); } ZoneId zone = ZoneId.of(ANALYTICS_TIME_ZONE); @@ -274,44 +263,34 @@ private AuditLogsAnalyticsResponse computeAuditLogsAnalytics(Server server, Stri return new AuditLogsAnalyticsResponse(byLevel, hourlyTrend); } - public PlayerActivityResponse getPlayerActivityAnalytics(Server server, String period) { + @NotNull + public PlayerActivityResponse getPlayerActivityAnalytics(@NotNull Server server, @NotNull String period) { return playerActivityCache.get(cacheKey(server, period), key -> computePlayerActivityAnalytics(server, period)); } private PlayerActivityResponse computePlayerActivityAnalytics(Server server, String period) { Date startDate = DateRangeUtil.getStartDate(period); - Document facetResults = analyticsRepository.aggregatePlayerActivity(server, startDate, ANALYTICS_TIME_ZONE); - if (facetResults == null) { + PlayerActivityFacet facet = analyticsRepository.aggregatePlayerActivity(server, startDate, ANALYTICS_TIME_ZONE); + if (facet == null) { return new PlayerActivityResponse(List.of(), List.of(), new PlayerActivityResponse.SuspiciousActivity(0, 0)); } - List newPlayersTrend = toDocumentList(facetResults.get("newPlayers")).stream() - .map(doc -> new PlayerActivityResponse.DailyCount( - doc.getString("_id"), - toInt(doc.get("count")))) + List newPlayersTrend = facet.newPlayers().stream() + .map(entry -> new PlayerActivityResponse.DailyCount(entry.date(), entry.count())) .toList(); - List loginsByCountry = toDocumentList(facetResults.get("byCountry")).stream() - .map(doc -> new PlayerActivityResponse.CountryCount( - doc.getString("_id"), - toInt(doc.get("count")))) + List loginsByCountry = facet.byCountry().stream() + .map(entry -> new PlayerActivityResponse.CountryCount(entry.country(), entry.count())) .toList(); - List suspiciousList = toDocumentList(facetResults.get("suspicious")); - int proxyCount = 0; - int hostingCount = 0; - if (!suspiciousList.isEmpty()) { - Document suspicious = suspiciousList.getFirst(); - proxyCount = toInt(suspicious.get("proxyCount")); - hostingCount = toInt(suspicious.get("hostingCount")); - } - return new PlayerActivityResponse( newPlayersTrend, loginsByCountry, - new PlayerActivityResponse.SuspiciousActivity(proxyCount, hostingCount) + new PlayerActivityResponse.SuspiciousActivity( + facet.suspicious().proxyCount(), + facet.suspicious().hostingCount()) ); } diff --git a/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java b/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java index ed09831..df86d74 100644 --- a/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java +++ b/src/main/java/gg/modl/backend/analytics/service/MetricSnapshotService.java @@ -2,7 +2,7 @@ import gg.modl.backend.database.mongo.repository.MetricSnapshotMongoRepository; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerMetricsRepository; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; @@ -15,7 +15,9 @@ @RequiredArgsConstructor @Slf4j public class MetricSnapshotService { - private final ServerMongoRepository serverRepository; + private static final long FIVE_MINUTE_BOUNDARY_SECONDS = 300; + + private final ServerMetricsRepository serverMetricsRepository; private final MetricSnapshotMongoRepository metricSnapshotRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; @@ -24,12 +26,12 @@ public void takeSnapshot() { try { Instant nowInstant = Instant.now(); Date now = Date.from(nowInstant); - // Truncate to 5-minute boundary long epochSeconds = nowInstant.getEpochSecond(); - Date fiveTruncated = Date.from(Instant.ofEpochSecond((epochSeconds / 300) * 300)); + Date fiveTruncated = Date.from(Instant.ofEpochSecond( + (epochSeconds / FIVE_MINUTE_BOUNDARY_SECONDS) * FIVE_MINUTE_BOUNDARY_SECONDS)); Date fiveMinutesAgo = Date.from(nowInstant.minus(5, ChronoUnit.MINUTES)); - long activeServers = serverRepository.countActiveSince(fiveMinutesAgo); + long activeServers = serverMetricsRepository.countActiveSince(fiveMinutesAgo); metricSnapshotRepository.upsertSnapshot( fiveTruncated, 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 9470290..b043f41 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealController.java @@ -1,6 +1,8 @@ package gg.modl.backend.appeal.controller; import gg.modl.backend.appeal.service.AppealService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; @@ -9,6 +11,7 @@ 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.AddAppealReplyRequest; import gg.modl.proto.modl.v1.AddTicketReplyResponse; import gg.modl.proto.modl.v1.AppealTicketsResponse; import gg.modl.proto.modl.v1.PanelResource; @@ -27,6 +30,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_APPEALS) +@RequiresPanelPermission(view = "ticket.view.all", modify = "appeal.modify") @RequiredArgsConstructor public class PanelAppealController { private final AppealService appealService; @@ -58,9 +62,10 @@ public ResponseEntity getAppealById( } @PostMapping("/{id}/replies") + @RequiresPanelPermission(rule = PanelAccessRule.APPEAL_REPLY) public ResponseEntity addReply( @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, + @RequestBody AddAppealReplyRequest replyRequest, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); diff --git a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java index cfda64c..e94dab8 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PanelAppealProtoMapper.java @@ -2,9 +2,10 @@ import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.addAll; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.structToMap; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.valuesToObjects; -import com.google.protobuf.Value; import gg.modl.backend.appeal.dto.request.AddAppealReplyRequest; +import gg.modl.backend.appeal.dto.request.CreateAppealRequest; import gg.modl.backend.appeal.dto.request.UpdateAppealStatusRequest; import gg.modl.backend.ticket.controller.PanelTicketProtoMapper; import gg.modl.backend.ticket.data.TicketReply; @@ -34,17 +35,17 @@ static AddTicketReplyResponse toAddReplyResponse(TicketReply reply) { .build(); } - static gg.modl.backend.appeal.dto.request.CreateAppealRequest fromCreateAppealRequest( + static CreateAppealRequest fromCreateAppealRequest( gg.modl.proto.modl.v1.CreateAppealRequest request ) { - return new gg.modl.backend.appeal.dto.request.CreateAppealRequest( + return new CreateAppealRequest( request.getPunishmentId(), request.getPlayerUuid(), request.getEmail(), request.hasReason() ? request.getReason() : null, request.hasEvidence() ? request.getEvidence() : null, request.hasAdditionalData() ? structToMap(request.getAdditionalData()) : null, - valueListToObjects(request.getAttachmentsList()), + valuesToObjects(request.getAttachmentsList()), request.getFieldLabelsMap().isEmpty() ? null : java.util.Map.copyOf(request.getFieldLabelsMap()) ); } @@ -57,7 +58,7 @@ static AddAppealReplyRequest fromAddAppealReplyRequest(gg.modl.proto.modl.v1.Add request.getStaff(), request.hasAction() ? request.getAction() : null, request.hasAvatar() ? request.getAvatar() : null, - valueListToObjects(request.getAttachmentsList()) + valuesToObjects(request.getAttachmentsList()) ); } @@ -70,23 +71,4 @@ static UpdateAppealStatusRequest fromUpdateAppealStatusRequest(gg.modl.proto.mod ); } - static List valueListToObjects(List values) { - if (values.isEmpty()) { - return null; - } - return values.stream().map(PanelAppealProtoMapper::valueToObject).toList(); - } - - private static Object valueToObject(Value value) { - return switch (value.getKindCase()) { - case NULL_VALUE, KIND_NOT_SET -> null; - case NUMBER_VALUE -> value.getNumberValue(); - case STRING_VALUE -> value.getStringValue(); - case BOOL_VALUE -> value.getBoolValue(); - case STRUCT_VALUE -> structToMap(value.getStructValue()); - case LIST_VALUE -> value.getListValue().getValuesList().stream() - .map(PanelAppealProtoMapper::valueToObject) - .toList(); - }; - } } 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 f5653f4..be9d16d 100644 --- a/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java +++ b/src/main/java/gg/modl/backend/appeal/controller/PublicAppealController.java @@ -1,5 +1,7 @@ package gg.modl.backend.appeal.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.valuesToObjects; + import gg.modl.backend.appeal.service.AppealService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -13,8 +15,11 @@ 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.AddAppealReplyRequest; +import gg.modl.proto.modl.v1.CreateAppealRequest; import gg.modl.proto.modl.v1.CreatePublicAppealResponse; import gg.modl.proto.modl.v1.PanelResource; +import gg.modl.proto.modl.v1.VerifyTicketCodeRequest; import jakarta.servlet.http.HttpServletRequest; import java.util.List; import lombok.RequiredArgsConstructor; @@ -61,7 +66,7 @@ public ResponseEntity getAppeal( @PostMapping public ResponseEntity createAppeal( - @RequestBody gg.modl.proto.modl.v1.CreateAppealRequest createRequest, + @RequestBody CreateAppealRequest createRequest, @RequestParam(value = "token", required = false) String appealToken, HttpServletRequest request ) { @@ -75,7 +80,7 @@ public ResponseEntity createAppeal( @PostMapping("/{id}/replies") public ResponseEntity addReply( @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.AddAppealReplyRequest replyRequest, + @RequestBody AddAppealReplyRequest replyRequest, @RequestParam(value = "token", required = false) String appealToken, HttpServletRequest request ) { @@ -91,7 +96,7 @@ public ResponseEntity addReply( .body(PublicVerificationProtoMapper.toVerificationRequiredResponse(id, access.emailHint())); } - List attachments = PanelAppealProtoMapper.valueListToObjects(replyRequest.getAttachmentsList()); + List attachments = valuesToObjects(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)); @@ -113,7 +118,7 @@ public ResponseEntity requestVerification(@PathVariable String id, HttpServle @PostMapping("/{id}/verify") public ResponseEntity verifyCode( @PathVariable String id, - @RequestBody gg.modl.proto.modl.v1.VerifyTicketCodeRequest body, + @RequestBody VerifyTicketCodeRequest body, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); diff --git a/src/main/java/gg/modl/backend/appeal/service/AppealService.java b/src/main/java/gg/modl/backend/appeal/service/AppealService.java index 49342c7..eb79341 100644 --- a/src/main/java/gg/modl/backend/appeal/service/AppealService.java +++ b/src/main/java/gg/modl/backend/appeal/service/AppealService.java @@ -33,13 +33,13 @@ import java.util.Date; import java.util.HashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.util.UuidUtils; import org.springframework.stereotype.Service; @Service @@ -109,7 +109,7 @@ public TicketResponse toResponse(Ticket appeal) { } public TicketResponse createAppeal(Server server, CreateAppealRequest request, String presentedToken) { - String playerUuid = normalizeUuid(request.playerUuid()); + String playerUuid = UuidUtils.normalize(request.playerUuid()); Player player = findPlayerWithPunishment(server, playerUuid, request.punishmentId()); if (player == null) { throw new ResourceNotFoundException("Punishment not found for the specified player"); @@ -356,7 +356,7 @@ private void addAppealRejectedNote(Server server, Ticket appeal, String staffUse } String punishmentId = (String) data.get("punishmentId"); - String playerUuid = normalizeUuid((String) data.get("playerUuid")); + String playerUuid = UuidUtils.normalize((String) data.get("playerUuid")); if (punishmentId == null || playerUuid == null) { return; @@ -373,12 +373,7 @@ private void addAppealRejectedNote(Server server, Ticket appeal, String staffUse null ); - Map dataUpdates = Map.of( - "data.appealOutcome", "Rejected", - "data.appealTicketId", appeal.getId() - ); - - punishmentMutationService.addPunishmentNote(server, playerUuid, punishmentId, appealRejectedNote, dataUpdates); + punishmentMutationService.recordAppealRejection(server, playerUuid, punishmentId, appealRejectedNote, appeal.getId()); } private void pardonPunishment(Server server, Ticket appeal, String staffUsername) { @@ -388,7 +383,7 @@ private void pardonPunishment(Server server, Ticket appeal, String staffUsername } String punishmentId = (String) data.get("punishmentId"); - String playerUuid = normalizeUuid((String) data.get("playerUuid")); + String playerUuid = UuidUtils.normalize((String) data.get("playerUuid")); if (punishmentId == null || playerUuid == null) { return; @@ -427,8 +422,7 @@ private void pardonPunishment(Server server, Ticket appeal, String staffUsername .filter(p -> punishmentId.equals(p.getId())) .findFirst() .orElse(null); - if (appealedPunishment != null && appealedPunishment.getData() != null - && Boolean.TRUE.equals(appealedPunishment.getData().get("altBlocking"))) { + if (appealedPunishment != null && appealedPunishment.data().altBlocking()) { punishmentLifecycleService.cascadePardonLinkedBans(server, punishmentId); } }); @@ -445,8 +439,4 @@ private TicketReply createSystemReply(String staffUsername, String content, Stri .action(action) .build(); } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } 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 c3fa5c1..4e00287 100644 --- a/src/main/java/gg/modl/backend/audit/controller/AuditController.java +++ b/src/main/java/gg/modl/backend/audit/controller/AuditController.java @@ -1,8 +1,10 @@ package gg.modl.backend.audit.controller; +import gg.modl.backend.audit.service.AdminDatabaseBrowserService; 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.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -43,10 +45,12 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_AUDIT) +@RequiresPanelPermission("admin.audit.view.logs") @RequiredArgsConstructor @Validated public class AuditController { private final AuditService auditService; + private final AdminDatabaseBrowserService adminDatabaseBrowserService; private final StaffPerformanceService staffPerformanceService; private final PermissionService permissionService; private final RealtimeEventPublisher realtimeEventPublisher; @@ -93,7 +97,26 @@ public ResponseEntity getPunishments( return ResponseEntity.ok(AuditProtoMapper.toPunishmentAuditList(punishments)); } + @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 (!AdminDatabaseBrowserService.ALLOWED_TABLES.contains(table)) { + throw new ValidationException("Invalid table name"); + } + + Map result = adminDatabaseBrowserService.getDatabaseTable(server, table, limit, skip); + return ResponseEntity.ok(AuditProtoMapper.toDatabaseTableResponse(result)); + } + @PostMapping("/punishments/{id}/rollback") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) public ResponseEntity rollbackPunishment( @PathVariable String id, @RequestBody(required = false) RollbackRequest rollbackRequest, @@ -116,6 +139,7 @@ public ResponseEntity rollbackPunishment( } @PostMapping("/staff/{username}/rollback-all") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) public ResponseEntity rollbackAllByStaff( @PathVariable String username, @RequestBody(required = false) RollbackRequest rollbackRequest, @@ -135,6 +159,7 @@ public ResponseEntity rollbackAllByStaff( } @PostMapping("/staff/{username}/rollback-date-range") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) public ResponseEntity rollbackByDateRange( @PathVariable String username, @RequestBody DateRangeRollbackRequest rollbackRequest, @@ -166,6 +191,7 @@ public ResponseEntity rollbackByDateRange( } @PostMapping("/punishments/bulk-pardon") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) public ResponseEntity bulkPardon( @RequestBody BulkPunishmentActionRequest actionRequest, HttpServletRequest request @@ -183,6 +209,7 @@ public ResponseEntity bulkPardon( } @PostMapping("/punishments/bulk-set-expiration") + @RequiresPanelPermission(PermissionService.ADMIN_AUDIT_ROLLBACK) public ResponseEntity bulkSetExpiration( @RequestBody BulkPunishmentActionRequest actionRequest, HttpServletRequest request @@ -214,22 +241,4 @@ private void requireSuperAdmin(Server server, HttpServletRequest request) { 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/service/AdminDatabaseBrowserService.java b/src/main/java/gg/modl/backend/audit/service/AdminDatabaseBrowserService.java new file mode 100644 index 0000000..5b83af6 --- /dev/null +++ b/src/main/java/gg/modl/backend/audit/service/AdminDatabaseBrowserService.java @@ -0,0 +1,97 @@ +package gg.modl.backend.audit.service; + +import gg.modl.backend.database.CollectionName; +import gg.modl.backend.database.mongo.repository.AdminDatabaseBrowserRepository; +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AdminDatabaseBrowserService { + + private final AdminDatabaseBrowserRepository databaseBrowserRepository; + + 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]"; + + public Map getDatabaseTable( + Server server, String table, int limit, int skip) { + if (!ALLOWED_TABLES.contains(table)) { + throw new ValidationException("Invalid table name"); + } + + List documents = databaseBrowserRepository.readTable(server, table, limit, skip); + long total = databaseBrowserRepository.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())); + } +} diff --git a/src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java b/src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java new file mode 100644 index 0000000..327e2f8 --- /dev/null +++ b/src/main/java/gg/modl/backend/audit/service/AuditProjectionKeys.java @@ -0,0 +1,17 @@ +package gg.modl.backend.audit.service; + +final class AuditProjectionKeys { + private AuditProjectionKeys() { + } + + static final String ISSUER_NAME = "issuerName"; + static final String ISSUER_ID = "issuerId"; + static final String TYPE_ORDINAL = "typeOrdinal"; + static final String PUNISHMENT_ID = "punishmentId"; + static final String PLAYER_ID = "playerId"; + static final String DATA = "data"; + static final String MODIFICATIONS = "modifications"; + static final String EVIDENCE = "evidence"; + static final String REASON = "reason"; + static final String DURATION = "duration"; +} 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 7c8e294..8ad3828 100644 --- a/src/main/java/gg/modl/backend/audit/service/AuditService.java +++ b/src/main/java/gg/modl/backend/audit/service/AuditService.java @@ -5,12 +5,12 @@ 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.database.mongo.repository.AuditLogRepository; +import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; 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; @@ -44,7 +44,9 @@ @Slf4j public class AuditService { - private final AuditMongoRepository auditRepository; + private final AuditLogRepository auditLogRepository; + private final PunishmentMongoRepository punishmentRepository; + private final StaffMongoRepository staffMongoRepository; private final PunishmentTypeService punishmentTypeService; private final StaffService staffService; private final PlayerStatusCalculator statusCalculator; @@ -56,32 +58,13 @@ public class AuditService { .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); + auditLogRepository.findPunishmentLogs(server, thirtyDaysAgo, limit, canRollbackOnly); return logs.stream().map(logEntry -> { Map metadata = logEntry.getMetadata() != null @@ -131,10 +114,6 @@ private String getStringFromMetadata( 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)); @@ -158,12 +137,12 @@ public List getPunishmentsList(Server server, String s private List computeAllPunishments(Server server) { List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); - List rows = auditRepository.aggregatePunishmentRows(server); + List rows = punishmentRepository.aggregatePunishmentRows(server); Map resolvedIssuers = resolveIssuerNames(server, rows); List results = new ArrayList<>(); for (Document row : rows) { - Punishment punishment = reconstructPunishment(row); + Punishment punishment = PunishmentDocumentMapper.reconstructPunishment(row); boolean active = statusCalculator.isPunishmentActive(punishment); results.add(mapToActivePunishmentResponse( server, row, punishment, active, typesByOrdinal, resolvedIssuers)); @@ -174,17 +153,17 @@ private List computeAllPunishments(Server server) { private ActivePunishmentResponse mapToActivePunishmentResponse( Server server, Document row, Punishment punishment, boolean active, Map typesByOrdinal, Map resolvedIssuers) { - int typeOrdinal = row.getInteger("typeOrdinal", 0); + int typeOrdinal = row.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 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; + Document data = row.get(AuditProjectionKeys.DATA, Document.class); + String reason = data != null ? data.getString(AuditProjectionKeys.REASON) : null; Long duration = extractDuration(data); - List evidenceItems = extractEvidenceItems(row); + List evidenceItems = PunishmentDocumentMapper.extractEvidenceItems(row); List ticketIds = row.getList("attachedTicketIds", String.class); if (ticketIds == null) { @@ -192,15 +171,15 @@ private ActivePunishmentResponse mapToActivePunishmentResponse( } return new ActivePunishmentResponse( - row.getString("punishmentId"), - row.getString("playerId"), + row.getString(AuditProjectionKeys.PUNISHMENT_ID), + row.getString(AuditProjectionKeys.PLAYER_ID), AuditDocumentUtil.extractPlayerNameFromDoc(row), typeName, typeOrdinal, category, resolveIssuerFromDoc( - row.getString("issuerId"), - row.getString("issuerName"), + row.getString(AuditProjectionKeys.ISSUER_ID), + row.getString(AuditProjectionKeys.ISSUER_NAME), resolvedIssuers), reason, duration, @@ -215,24 +194,6 @@ private ActivePunishmentResponse mapToActivePunishmentResponse( ); } - 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)) { @@ -248,7 +209,7 @@ private Long extractDuration(Document data) { if (data == null) { return null; } - Object durationObj = data.get("duration"); + Object durationObj = data.get(AuditProjectionKeys.DURATION); if (durationObj instanceof Number number) { return number.longValue(); } @@ -258,73 +219,17 @@ private Long extractDuration(Document data) { private Map resolveIssuerNames(Server server, List rows) { Set issuerIds = new HashSet<>(); for (Document doc : rows) { - String issuerId = doc.getString("issuerId"); + String issuerId = doc.getString(AuditProjectionKeys.ISSUER_ID); 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; + return staffMongoRepository.findUsernamesByIds(server, issuerIds); } public boolean rollbackPunishment( Server server, String punishmentId, String reason, String performerUsername) { - Document player = auditRepository.findPlayerByPunishmentId(server, punishmentId); + Document player = punishmentRepository.findPlayerByPunishmentId(server, punishmentId); if (player == null) { return false; } @@ -338,7 +243,7 @@ public boolean rollbackPunishment( Date now = new Date(); String playerId = player.getString("_id"); - auditRepository.appendPunishmentModification( + punishmentRepository.appendPunishmentModification( server, playerId, punishmentId, buildRollbackModification(performerUsername, reason, now)); saveRollbackAuditLog( server, playerId, @@ -398,63 +303,7 @@ private void saveRollbackAuditLog( )) .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())); + auditLogRepository.saveAuditLog(server, rollbackLog); } public int rollbackAllPunishmentsByStaff( @@ -471,7 +320,7 @@ private int rollbackPunishmentsInternal( Date startDate, Date endDate, String reason, String performerUsername) { try { List players = - auditRepository.findPlayersForRollback(server, staffUsername, staffId); + punishmentRepository.findPlayersForRollback(server, staffUsername, staffId); Date now = new Date(); int rollbackCount = 0; @@ -510,7 +359,7 @@ private int applyRollbackToPlayer( continue; } - auditRepository.appendPunishmentModification( + punishmentRepository.appendPunishmentModification( server, playerId, punishment.getString("id"), buildRollbackModification(performerUsername, reason, now)); saveRollbackAuditLog( @@ -567,7 +416,7 @@ public int bulkPardonByType( AuditLog pardonLog = buildBulkAuditLog(ctx, performerUsername, "Bulk pardon: " + ctx.typeName + " for " + ctx.playerName, Map.of("pardonReason", reason != null ? reason : "", "bulkPardon", true)); - auditRepository.saveAuditLog(server, pardonLog); + auditLogRepository.saveAuditLog(server, pardonLog); return true; }); } @@ -589,7 +438,7 @@ public int bulkSetExpirationByType( "Bulk duration change: " + ctx.typeName + " for " + ctx.playerName, Map.of("reason", reason != null ? reason : "", "newDurationMs", newDurationMs, "bulkDurationChange", true)); - auditRepository.saveAuditLog(server, durationLog); + auditLogRepository.saveAuditLog(server, durationLog); return true; }); } @@ -598,7 +447,7 @@ private int processBulkPunishmentAction( Server server, List typeOrdinals, String reason, String performerUsername, String operationName, BulkPunishmentAction action) { try { - List players = auditRepository.findPlayersForBulkAction(server, typeOrdinals); + List players = punishmentRepository.findPlayersForBulkAction(server, typeOrdinals); Date now = new Date(); int count = 0; @@ -617,12 +466,12 @@ private int processBulkPunishmentAction( String playerName = AuditDocumentUtil.extractPlayerNameFromDoc(player); for (Document punishmentDoc : punishments) { - int typeOrdinal = punishmentDoc.getInteger("typeOrdinal", 0); + int typeOrdinal = punishmentDoc.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0); if (!typeOrdinals.contains(typeOrdinal)) { continue; } - Punishment punishment = reconstructPunishment(punishmentDoc); + Punishment punishment = PunishmentDocumentMapper.reconstructPunishment(punishmentDoc); if (!statusCalculator.isPunishmentActive(punishment)) { continue; } diff --git a/src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java b/src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java new file mode 100644 index 0000000..d31defd --- /dev/null +++ b/src/main/java/gg/modl/backend/audit/service/PunishmentDocumentMapper.java @@ -0,0 +1,91 @@ +package gg.modl.backend.audit.service; + +import gg.modl.backend.audit.dto.response.ActivePunishmentResponse; +import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentModification; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import org.bson.Document; + +final class PunishmentDocumentMapper { + private PunishmentDocumentMapper() { + } + + static Punishment reconstructPunishment(Document doc) { + Punishment punishment = new Punishment(); + String reconstructedId = doc.getString(AuditProjectionKeys.PUNISHMENT_ID); + if (reconstructedId == null) { + reconstructedId = doc.getString("id"); + } + punishment.setId(reconstructedId); + punishment.setTypeOrdinal(doc.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0)); + punishment.setIssuerName( + doc.getString(AuditProjectionKeys.ISSUER_NAME) != null + ? doc.getString(AuditProjectionKeys.ISSUER_NAME) : "Unknown"); + punishment.setIssuerId(doc.getString(AuditProjectionKeys.ISSUER_ID)); + punishment.setIssued( + doc.getDate("issued") != null ? doc.getDate("issued") : new Date()); + punishment.setStarted(doc.getDate("started")); + + Document data = doc.get(AuditProjectionKeys.DATA, Document.class); + if (data != null) { + punishment.replaceData(new HashMap<>(data)); + } + + punishment.setModifications(extractModifications(doc)); + punishment.setNotes(Collections.emptyList()); + punishment.setEvidence(Collections.emptyList()); + punishment.setAttachedTicketIds(Collections.emptyList()); + + return punishment; + } + + static List extractModifications(Document doc) { + List modDocs = doc.getList(AuditProjectionKeys.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(AuditProjectionKeys.ISSUER_NAME), + modDoc.getString(AuditProjectionKeys.ISSUER_ID), + modDoc.getString(AuditProjectionKeys.REASON), + effectiveDuration, + modDoc.getString("appealTicketId"), + null + )); + } + return mods; + } + + static List extractEvidenceItems(Document row) { + List evidenceDocs = row.getList(AuditProjectionKeys.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; + } +} diff --git a/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java b/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java index c330259..0c9b31c 100644 --- a/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java +++ b/src/main/java/gg/modl/backend/audit/service/StaffPerformanceService.java @@ -2,10 +2,13 @@ import gg.modl.backend.audit.dto.response.StaffDetailsResponse; import gg.modl.backend.audit.dto.response.StaffPerformanceResponse; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository.IdCountResult; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository.OrdinalCountResult; -import gg.modl.backend.database.mongo.repository.AuditMongoRepository.StaffActivityResult; +import gg.modl.backend.database.mongo.repository.AuditLogRepository; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.IdCountResult; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.OrdinalCountResult; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.StaffActivityResult; +import gg.modl.backend.database.mongo.repository.StaffActivityAnalyticsRepository.StaffTicketResponseTime; +import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.infrastructure.util.DateRangeUtil; import gg.modl.backend.player.data.punishment.PunishmentModificationType; import gg.modl.backend.role.service.PermissionService; @@ -34,7 +37,9 @@ @Slf4j public class StaffPerformanceService { - private final AuditMongoRepository auditRepository; + private final StaffActivityAnalyticsRepository staffActivityAnalyticsRepository; + private final AuditLogRepository auditLogRepository; + private final StaffMongoRepository staffMongoRepository; private final PunishmentTypeService punishmentTypeService; private final StaffService staffService; private final PermissionService permissionService; @@ -42,14 +47,16 @@ public class StaffPerformanceService { public List getStaffPerformance(Server server, String period) { Date startDate = DateRangeUtil.getStartDate(period); - List allStaff = auditRepository.findAllStaff(server); + List allStaff = staffMongoRepository.findAllStaff(server); Map roleNamesById = permissionService.resolveRoleNames(server, allStaff.stream().map(Staff::getRoleId).toList()); Map activityByUsername = indexStaffActivity( - auditRepository.aggregateLogActivityBySource(server, startDate)); + staffActivityAnalyticsRepository.aggregateLogActivityBySource(server, startDate)); Map ticketResponsesByStaff = indexIdCounts( - auditRepository.aggregateTicketResponseCounts(server, startDate)); + staffActivityAnalyticsRepository.aggregateTicketResponseCounts(server, startDate)); Map punishmentsByStaff = countPunishmentsByStaff(server, startDate); + Map> ticketResponseTimesByStaff = indexTicketResponseTimes( + staffActivityAnalyticsRepository.aggregateTicketResponseTimesByStaff(server, startDate)); List performanceList = new ArrayList<>(); for (Staff staff : allStaff) { @@ -84,6 +91,10 @@ public List getStaffPerformance(Server server, String String roleName = staff.getRoleId() != null && !staff.getRoleId().isBlank() ? roleNamesById.getOrDefault(staff.getRoleId(), staff.getRoleId()) : "User"; + int avgResponseTime = averageOf( + ticketResponseTimesByStaff.getOrDefault(lowerUsername, List.of()).stream() + .map(source -> calculateResponseTimeMinutes(source.ticketCreated(), source.firstReply())) + .toList()); performanceList.add(new StaffPerformanceResponse( staff.getId(), username, @@ -91,7 +102,7 @@ public List getStaffPerformance(Server server, String totalActions, ticketActions, moderationActions, - 60, + avgResponseTime, lastActive != null ? lastActive : new Date() )); } @@ -111,6 +122,17 @@ private Map indexStaffActivity(List> indexTicketResponseTimes( + List results) { + Map> map = new HashMap<>(); + for (StaffTicketResponseTime result : results) { + if (result.staff() != null) { + map.computeIfAbsent(result.staff().toLowerCase(), key -> new ArrayList<>()).add(result); + } + } + return map; + } + private Map indexIdCounts(List results) { Map map = new HashMap<>(); for (IdCountResult result : results) { @@ -124,7 +146,7 @@ private Map indexIdCounts(List results) { private Map countPunishmentsByStaff(Server server, Date startDate) { Map counts = new HashMap<>(); List results = - auditRepository.aggregatePunishmentCountsByIssuer(server, startDate); + staffActivityAnalyticsRepository.aggregatePunishmentCountsByIssuer(server, startDate); Set issuerIdsToResolve = new HashSet<>(); for (IdCountResult result : results) { @@ -133,7 +155,7 @@ private Map countPunishmentsByStaff(Server server, Date startDa } } Map resolvedIds = - auditRepository.mapStaffUsernamesByIds(server, issuerIdsToResolve); + staffMongoRepository.findUsernamesByIds(server, issuerIdsToResolve); for (IdCountResult result : results) { if (result.id() == null) { @@ -172,13 +194,8 @@ public StaffDetailsResponse getStaffDetails(Server server, String username, Stri List typeBreakdown = getPunishmentTypeBreakdown(server, usernamesToSearch, staffId, startDate); - long evidenceUploads = auditRepository.countEvidenceUploads(server, username, startDate); - int avgResponseTime = tickets.isEmpty() - ? 0 - : (int) tickets.stream() - .mapToInt(StaffDetailsResponse.TicketDetail::responseTime) - .average() - .orElse(0); + long evidenceUploads = auditLogRepository.countEvidenceUploads(server, username, startDate); + int avgResponseTime = averageResponseTimeMinutes(tickets); StaffDetailsResponse.Summary summary = new StaffDetailsResponse.Summary( punishments.size(), @@ -203,16 +220,16 @@ private List getPunishmentDetails( Server server, List usernames, String staffId, Date startDate) { List details = new ArrayList<>(); List results = - auditRepository.aggregatePunishmentDetails(server, usernames, staffId, startDate); + staffActivityAnalyticsRepository.aggregatePunishmentDetails(server, usernames, staffId, startDate); for (Document doc : results) { - int typeOrdinal = doc.getInteger("typeOrdinal", 0); - String reason = doc.getString("reason"); - Object durationObj = doc.get("duration"); + int typeOrdinal = doc.getInteger(AuditProjectionKeys.TYPE_ORDINAL, 0); + String reason = doc.getString(AuditProjectionKeys.REASON); + Object durationObj = doc.get(AuditProjectionKeys.DURATION); details.add(new StaffDetailsResponse.PunishmentDetail( - doc.getString("punishmentId"), - doc.getString("playerId"), + doc.getString(AuditProjectionKeys.PUNISHMENT_ID), + doc.getString(AuditProjectionKeys.PLAYER_ID), AuditDocumentUtil.extractPlayerNameFromDoc(doc), punishmentTypeService.getPunishmentTypeName(server, typeOrdinal), reason != null ? reason : "No reason provided", @@ -229,7 +246,7 @@ private List getTicketDetails( Server server, String username, Date startDate) { List details = new ArrayList<>(); List results = - auditRepository.aggregateTicketDetails(server, username, startDate); + staffActivityAnalyticsRepository.aggregateTicketDetails(server, username, startDate); for (Document doc : results) { int responseTime = calculateResponseTimeMinutes( @@ -251,6 +268,22 @@ private List getTicketDetails( return details; } + private static int averageResponseTimeMinutes(List tickets) { + return averageOf(tickets.stream() + .map(StaffDetailsResponse.TicketDetail::responseTime) + .toList()); + } + + private static int averageOf(List responseTimes) { + if (responseTimes.isEmpty()) { + return 0; + } + return (int) responseTimes.stream() + .mapToInt(Integer::intValue) + .average() + .orElse(0); + } + private int calculateResponseTimeMinutes(Date ticketCreated, Date firstReply) { if (ticketCreated == null || firstReply == null) { return 0; @@ -264,7 +297,7 @@ private List getDailyActivity( Map activityByDate = new HashMap<>(); List punishmentResults = - auditRepository.aggregateDailyPunishmentCounts( + staffActivityAnalyticsRepository.aggregateDailyPunishmentCounts( server, usernames, staffId, startDate); for (IdCountResult result : punishmentResults) { activityByDate.put(result.id(), @@ -272,7 +305,7 @@ private List getDailyActivity( } List ticketResults = - auditRepository.aggregateDailyTicketResponseCounts( + staffActivityAnalyticsRepository.aggregateDailyTicketResponseCounts( server, usernames.get(0), startDate); for (IdCountResult result : ticketResults) { StaffDetailsResponse.DailyActivity existing = activityByDate.get(result.id()); @@ -295,7 +328,7 @@ private List getPunishmentTypeBrea Server server, List usernames, String staffId, Date startDate) { List breakdown = new ArrayList<>(); List results = - auditRepository.aggregatePunishmentTypeBreakdown( + staffActivityAnalyticsRepository.aggregatePunishmentTypeBreakdown( server, usernames, staffId, startDate); for (OrdinalCountResult result : results) { diff --git a/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java b/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java index b16d717..88547fe 100644 --- a/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java +++ b/src/main/java/gg/modl/backend/auth/AuthResponseMessage.java @@ -1,12 +1,8 @@ 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/EmailChangeService.java b/src/main/java/gg/modl/backend/auth/EmailChangeService.java index 17029de..39fc89d 100644 --- a/src/main/java/gg/modl/backend/auth/EmailChangeService.java +++ b/src/main/java/gg/modl/backend/auth/EmailChangeService.java @@ -13,7 +13,7 @@ import gg.modl.backend.server.ServerService; 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.staff.service.StaffProfileService; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +25,7 @@ public class EmailChangeService { private final PermissionService permissionService; private final AuthService authService; - private final StaffService staffService; + private final StaffProfileService staffProfileService; private final ServerService serverService; private final WebAuthnService webAuthnService; private final BillingService billingService; @@ -52,7 +52,7 @@ public AuthSessionData changeEmail(Server server, String currentEmail, String ne serverService.changeAdminEmail(server, normalizedNewEmail); } - Optional staff = staffService.applyStaffEmailChange(server, currentEmail, normalizedNewEmail); + Optional staff = staffProfileService.applyStaffEmailChange(server, currentEmail, normalizedNewEmail); if (staff.isEmpty() && !isSuperAdmin) { throw new ResourceNotFoundException("Staff member not found"); } @@ -80,7 +80,7 @@ private String validateTarget(Server server, String currentEmail, String newEmai boolean collidesWithServerAdmin = server.getAdminEmail() != null && normalizedNewEmail.equalsIgnoreCase(server.getAdminEmail()); if (collidesWithServerAdmin - || staffService.isStaffEmailInUse(server, normalizedNewEmail, currentEmail) + || staffProfileService.isStaffEmailInUse(server, normalizedNewEmail, currentEmail) || (isSuperAdmin && serverService.isAdminEmailInUse(normalizedNewEmail, server.getId()))) { throw new ConflictException("Email address already in use"); } 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 019cee7..e8c896c 100644 --- a/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java +++ b/src/main/java/gg/modl/backend/auth/controller/PanelAuthController.java @@ -12,14 +12,15 @@ 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.settings.data.SupportedLanguages; import gg.modl.backend.staff.data.Staff; -import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.staff.service.StaffLookupCache; +import gg.modl.backend.staff.service.StaffProfileService; +import gg.modl.backend.staff.service.SuperAdminStaffSynthesizer; 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; @@ -53,7 +54,8 @@ public class PanelAuthController { private final AuthService authService; private final SessionService sessionService; private final AuthConfiguration authConfiguration; - private final StaffService staffService; + private final StaffProfileService staffProfileService; + private final StaffLookupCache staffLookupCache; private final PermissionService permissionService; private final CookieUtil cookieUtil; private final EmailChangeService emailChangeService; @@ -65,14 +67,13 @@ public ResponseEntity sendEmailCode( 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)); + return verificationCodeSentResponse(); } authService.sendUserLoginCode(server, requestData.getEmail()); - return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); + return verificationCodeSentResponse(); } @PostMapping("/verify-email-code") @@ -83,15 +84,14 @@ public ResponseEntity verifyEmailCode( 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)); + return invalidCodeResponse(); } boolean valid = authService.verifyCode(server, requestData.getEmail(), requestData.getCode()); if (!valid) { - return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); + return invalidCodeResponse(); } String clientIp = RequestUtil.getClientIp(request); @@ -147,22 +147,19 @@ public ResponseEntity updateProfile( String language = requestData.hasLanguage() ? requestData.getLanguage() : null; String dateFormat = requestData.hasDateFormat() ? requestData.getDateFormat() : null; - Optional result = staffService.updateOrCreateProfileUsername(server, email, username, isSuperAdmin, language, dateFormat); + Optional result = staffProfileService.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 resolvedUsername = username != null ? username : SuperAdminStaffSynthesizer.SUPER_ADMIN_USERNAME; + String resolvedLanguage = language != null ? language : SupportedLanguages.DEFAULT; String resolvedDateFormat = dateFormat != null ? dateFormat : Staff.DEFAULT_DATE_FORMAT; - return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( - null, email, resolvedUsername, "Super Admin", resolvedUsername, resolvedLanguage, resolvedDateFormat)); + return superAdminProfileResponse(email, 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(); + String role = isSuperAdmin ? RoleAuthorization.SUPER_ADMIN_ROLE_NAME : permissionService.resolveRoleName(server, staff.getRoleId()); + String minecraftUsername = minecraftUsernameOrPanel(staff); return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( staff.getId(), staff.getEmail(), staff.getUsername(), role, minecraftUsername, staff.getLanguage(), staff.getDateFormat())); } @@ -213,22 +210,18 @@ public ResponseEntity getCurrentUser(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); boolean isSuperAdmin = permissionService.isSuperAdmin(server, email); - Optional staffOpt = staffService.getStaffByEmail(server, email); + Optional staffOpt = staffLookupCache.findByEmail(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(); + String role = isSuperAdmin ? RoleAuthorization.SUPER_ADMIN_ROLE_NAME : permissionService.resolveRoleName(server, staff.getRoleId()); + String minecraftUsername = minecraftUsernameOrPanel(staff); 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", Staff.DEFAULT_DATE_FORMAT)); + return superAdminProfileResponse(email, SuperAdminStaffSynthesizer.SUPER_ADMIN_USERNAME, SupportedLanguages.DEFAULT, Staff.DEFAULT_DATE_FORMAT); } return ResponseEntity.status(404).body(PanelAuthProtoMapper.toAuthResponse(false, "Staff member not found")); @@ -301,6 +294,25 @@ private void expireSessionCookies(HttpServletResponse response) { } } + private ResponseEntity verificationCodeSentResponse() { + return ResponseEntity.ok(PanelAuthProtoMapper.toAuthResponse(true, AuthResponseMessage.VERIFICATION_CODE_SENT)); + } + + private ResponseEntity invalidCodeResponse() { + return ResponseEntity.badRequest().body(PanelAuthProtoMapper.toAuthResponse(false, AuthResponseMessage.INVALID_CODE)); + } + + private ResponseEntity superAdminProfileResponse(String email, String username, String language, String dateFormat) { + return ResponseEntity.ok(PanelAuthProtoMapper.toProfileResponse( + null, email, username, RoleAuthorization.SUPER_ADMIN_ROLE_NAME, username, language, dateFormat)); + } + + private static String minecraftUsernameOrPanel(Staff staff) { + return staff.getAssignedMinecraftUsername() != null + ? staff.getAssignedMinecraftUsername() + : staff.getUsername(); + } + @GetMapping("/permissions") public ResponseEntity getUserPermissions(HttpServletRequest request) { String email = RequestUtil.getSessionEmail(request); @@ -314,7 +326,7 @@ public ResponseEntity getUserPermissions(HttpServletRe return ResponseEntity.ok(PanelAuthProtoMapper.toPermissionsResponse(permissionService.getAllPermissionIds(server))); } - Optional staffOpt = staffService.getStaffByEmail(server, email); + Optional staffOpt = staffLookupCache.findByEmail(server, email); if (staffOpt.isEmpty()) { return 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 75fd9b2..9ec9212 100644 --- a/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java +++ b/src/main/java/gg/modl/backend/auth/controller/WebAuthnController.java @@ -44,13 +44,9 @@ public class WebAuthnController { 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"); - } + String email = requireSessionEmail(request); Server server = RequestUtil.getRequestServer(request); WebAuthnService.StartRegistrationResult result = webAuthnService.startRegistration(server, email); @@ -62,10 +58,7 @@ public ResponseEntity registerOptions(HttpServletRequest request) throws Json public ResponseEntity registerVerify( HttpServletRequest request, @RequestBody @Valid RegisterVerifyRequest body) throws Exception { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } + String email = requireSessionEmail(request); Server server = RequestUtil.getRequestServer(request); webAuthnService.finishRegistration(server, email, body.challengeId(), body.response(), body.name()); @@ -74,10 +67,7 @@ public ResponseEntity registerVerify( @GetMapping("/credentials") public ResponseEntity listCredentials(HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } + String email = requireSessionEmail(request); Server server = RequestUtil.getRequestServer(request); List credentials = webAuthnService.listCredentials(server, email); @@ -89,10 +79,7 @@ public ResponseEntity renameCredential( HttpServletRequest request, @PathVariable String id, @RequestBody RenameWebAuthnCredentialRequest body) { - String email = RequestUtil.getSessionEmail(request); - if (email == null) { - throw new UnauthorizedException("Not authenticated"); - } + String email = requireSessionEmail(request); Server server = RequestUtil.getRequestServer(request); boolean updated = webAuthnService.renameCredential(server, email, id, body.getName()); @@ -102,15 +89,11 @@ public ResponseEntity renameCredential( 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"); - } + String email = requireSessionEmail(request); Server server = RequestUtil.getRequestServer(request); boolean deleted = webAuthnService.deleteCredential(server, email, id); @@ -134,7 +117,6 @@ public ResponseEntity loginOptions( @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)); } @@ -153,7 +135,6 @@ public ResponseEntity loginOptions( )); } - @PostMapping("/login/verify") public ResponseEntity loginVerify( HttpServletRequest request, @@ -170,6 +151,13 @@ public ResponseEntity loginVerify( return ResponseEntity.ok(Map.of("success", true)); } + private String requireSessionEmail(HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (email == null) { + throw new UnauthorizedException("Not authenticated"); + } + return email; + } public record RegisterVerifyRequest( @NotBlank @Size(max = 256) String challengeId, diff --git a/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java b/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java index 5a4d78f..da904ef 100644 --- a/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java +++ b/src/main/java/gg/modl/backend/auth/session/SessionPublicId.java @@ -1,8 +1,6 @@ package gg.modl.backend.auth.session; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; +import gg.modl.backend.infrastructure.util.DigestUtils; public final class SessionPublicId { @@ -13,16 +11,6 @@ public static String of(String sessionId) { if (sessionId == null) { return null; } - try { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(sessionId.getBytes(StandardCharsets.UTF_8)); - StringBuilder hex = new StringBuilder(digest.length * 2); - for (byte b : digest) { - hex.append(Character.forDigit((b >> 4) & 0xF, 16)); - hex.append(Character.forDigit(b & 0xF, 16)); - } - return hex.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 not available", e); - } + return DigestUtils.sha256Hex(sessionId); } } diff --git a/src/main/java/gg/modl/backend/auth/session/SessionService.java b/src/main/java/gg/modl/backend/auth/session/SessionService.java index 5d221ae..cb3c00c 100644 --- a/src/main/java/gg/modl/backend/auth/session/SessionService.java +++ b/src/main/java/gg/modl/backend/auth/session/SessionService.java @@ -81,21 +81,6 @@ public Optional findAndRefreshSession(Server server, String ses ); } - public Optional findValidSession(Server server, String sessionId) { - if (sessionId == null || sessionId.isBlank()) { - return Optional.empty(); - } - return sessionRepository.findActiveById(server, sessionId, new Date()); - } - - public void refreshSession(Server server, String sessionId) { - sessionRepository.refreshExpiresAt(server, sessionId, nextExpiryDate()); - } - - private Date nextExpiryDate() { - return nextExpiryDate(new Date()); - } - public Optional findAndRefreshAdminSession(String sessionId) { return findAndRefreshInternal( sessionId, @@ -128,10 +113,6 @@ public Optional findValidAdminSession(String sessionId) { return sessionRepository.findActiveByIdGlobal(sessionId, new Date()); } - public void refreshAdminSession(String sessionId) { - sessionRepository.refreshExpiresAtGlobal(sessionId, nextExpiryDate()); - } - public void invalidateSession(Server server, String sessionId) { sessionRepository.deleteById(server, sessionId); } diff --git a/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java b/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java index 032dd57..1a9ba20 100644 --- a/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java +++ b/src/main/java/gg/modl/backend/beta/AdminBetaTesterController.java @@ -1,6 +1,5 @@ package gg.modl.backend.beta; -import gg.modl.backend.admin.service.AdminAuthService; import gg.modl.backend.infrastructure.filter.AdminAuthFilter; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.proto.modl.v1.BetaAuditResponse; @@ -83,10 +82,7 @@ public ResponseEntity audit( } private String actingAdminEmail(HttpServletRequest request) { - Object attribute = request.getAttribute(AdminAuthFilter.ADMIN_SESSION_ATTR); - if (attribute instanceof AdminAuthService.AdminSession session) { - return session.email(); - } - throw new BetaRequestException("Admin authentication required.", HttpStatus.UNAUTHORIZED); + return AdminAuthFilter.actingEmail(request) + .orElseThrow(() -> new BetaRequestException("Admin authentication required.", HttpStatus.UNAUTHORIZED)); } } diff --git a/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java b/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java index 157c691..d9ed4de 100644 --- a/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java +++ b/src/main/java/gg/modl/backend/beta/AdminBetaTesterService.java @@ -2,7 +2,9 @@ import gg.modl.backend.beta.data.BetaAudit; import gg.modl.backend.database.mongo.repository.AuthSessionMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerAdminRepository; +import gg.modl.backend.database.mongo.repository.ServerBetaTesterRepository; +import gg.modl.backend.database.mongo.repository.ServerProvisioningRepository; import gg.modl.backend.email.EmailAddressUtil; import gg.modl.backend.email.EmailHTMLTemplate; import gg.modl.backend.email.EmailService; @@ -29,7 +31,9 @@ public class AdminBetaTesterService { private static final int DEFAULT_PAGE_LIMIT = 50; - private final ServerMongoRepository serverRepository; + private final ServerBetaTesterRepository serverBetaTesterRepository; + private final ServerAdminRepository serverAdminRepository; + private final ServerProvisioningRepository serverProvisioningRepository; private final ServerService serverService; private final ServerProvisioningService provisioningService; private final RegistrationService registrationService; @@ -45,8 +49,8 @@ public BetaTesterPage list(int page, int limit, String search) { int normalizedLimit = PaginationHelper.normalizeLimit(limit, DEFAULT_PAGE_LIMIT); int skip = PaginationHelper.calculateSkip(page, normalizedLimit); - List servers = serverRepository.findBetaTesters(search, skip, normalizedLimit); - long total = serverRepository.countBetaTesters(search); + List servers = serverBetaTesterRepository.findBetaTesters(search, skip, normalizedLimit); + long total = serverBetaTesterRepository.countBetaTesters(search); int pages = PaginationHelper.calculateTotalPages(total, normalizedLimit); List items = servers.stream().map(this::details).toList(); @@ -54,7 +58,7 @@ public BetaTesterPage list(int page, int limit, String search) { } public BetaTesterDetails get(String id) { - Server server = serverRepository.findById(id) + Server server = serverBetaTesterRepository.findById(id) .filter(candidate -> candidate.getBetaTesterCreatedAt() != null) .orElseThrow(() -> new BetaRequestException("Beta tester not found.", HttpStatus.NOT_FOUND)); return details(server); @@ -78,21 +82,23 @@ public BetaTesterDetails create(BetaTesterCreation creation, String actingAdminE }); Server saved = persistBetaServer(trimmedName, subdomain, normalizedEmail, actingAdminEmail); + boolean tenantDatabaseExisted = provisioningService.tenantDatabaseExists(saved); try { provisioningService.provision(saved); } catch (Exception e) { - serverRepository.deleteByServerId(saved.getId()); + serverAdminRepository.deleteByServerId(saved.getId()); + provisioningService.teardownProvisionedDatabase(saved, tenantDatabaseExisted); serverService.evictAllServerCaches(); log.error("Beta provisioning failed for {}; rolled back the server row", subdomain, e); throw new BetaRequestException("Failed to provision beta tester panel. Please retry.", HttpStatus.INTERNAL_SERVER_ERROR); } registrationService.resolveOrGenerateApiKey(saved); - serverRepository.markProvisioningCompleted(saved.getId()); + serverProvisioningRepository.markProvisioningCompleted(saved.getId()); serverService.evictAllServerCaches(); - Server reloaded = serverRepository.findById(saved.getId()).orElse(saved); + Server reloaded = serverBetaTesterRepository.findById(saved.getId()).orElse(saved); betaAuditService.record(BetaAuditAction.CREATE, reloaded.getId(), actingAdminEmail, "Created beta tester panel " + subdomain); sendBetaReadyEmail(reloaded); @@ -120,7 +126,7 @@ private Server persistBetaServer(String serverName, String subdomain, String adm server.setBetaTesterCreatedBy(actingAdminEmail); server.setCreatedAt(now); server.setUpdatedAt(now); - Server saved = serverRepository.saveEntity(server); + Server saved = serverBetaTesterRepository.saveEntity(server); serverService.evictAllServerCaches(); return saved; } @@ -128,7 +134,7 @@ private Server persistBetaServer(String serverName, String subdomain, String adm public BetaTesterDetails revoke(String id, String actingAdminEmail) { requireActiveBetaTester(id); - Server updated = serverRepository.updateBetaState(id, ServerPlan.FREE, SubscriptionStatus.INACTIVE, false) + Server updated = serverBetaTesterRepository.updateBetaState(id, ServerPlan.FREE, SubscriptionStatus.INACTIVE, false) .orElseThrow(() -> new BetaRequestException("Failed to revoke beta tester.", HttpStatus.INTERNAL_SERVER_ERROR)); authSessionRepository.deleteAllForServer(updated); serverService.evictAllServerCaches(); @@ -160,7 +166,7 @@ public List audit(String id, int limit) { } private Server requireActiveBetaTester(String id) { - Server server = serverRepository.findById(id) + Server server = serverBetaTesterRepository.findById(id) .orElseThrow(() -> new BetaRequestException("Beta tester not found.", HttpStatus.NOT_FOUND)); if (!Boolean.TRUE.equals(server.getBetaTester())) { throw new BetaRequestException("Beta tester not found.", HttpStatus.NOT_FOUND); diff --git a/src/main/java/gg/modl/backend/beta/BetaAuditService.java b/src/main/java/gg/modl/backend/beta/BetaAuditService.java index 7ba6a84..5d07b33 100644 --- a/src/main/java/gg/modl/backend/beta/BetaAuditService.java +++ b/src/main/java/gg/modl/backend/beta/BetaAuditService.java @@ -16,12 +16,13 @@ public class BetaAuditService { private final BetaAuditMongoRepository auditRepository; public void record(BetaAuditAction action, String serverId, String adminEmail, String details) { - BetaAudit audit = new BetaAudit(); - audit.setAction(action.name()); - audit.setServerId(serverId); - audit.setAdminEmail(adminEmail); - audit.setTimestamp(new Date()); - audit.setDetails(details); + BetaAudit audit = BetaAudit.builder() + .action(action.name()) + .serverId(serverId) + .adminEmail(adminEmail) + .timestamp(new Date()) + .details(details) + .build(); auditRepository.saveEntity(audit); } diff --git a/src/main/java/gg/modl/backend/beta/BetaResetService.java b/src/main/java/gg/modl/backend/beta/BetaResetService.java index 6cf1185..6589829 100644 --- a/src/main/java/gg/modl/backend/beta/BetaResetService.java +++ b/src/main/java/gg/modl/backend/beta/BetaResetService.java @@ -8,7 +8,8 @@ import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; import gg.modl.backend.database.mongo.repository.ServerLogMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerBetaTesterRepository; +import gg.modl.backend.database.mongo.repository.ServerUsageRepository; import gg.modl.backend.database.mongo.repository.StorageFileMongoRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; import gg.modl.backend.database.mongo.repository.TicketVerificationMongoRepository; @@ -45,7 +46,8 @@ public class BetaResetService { private final WebAuthnChallengeMongoRepository webAuthnChallengeRepository; private final AuthSessionMongoRepository authSessionRepository; private final AuthCodeMongoRepository authCodeRepository; - private final ServerMongoRepository serverRepository; + private final ServerBetaTesterRepository serverBetaTesterRepository; + private final ServerUsageRepository serverUsageRepository; private final ServerService serverService; private final Set resetsInFlight = ConcurrentHashMap.newKeySet(); @@ -58,7 +60,7 @@ public List reset(Server server) { @Async public CompletableFuture> resetAll() { - List servers = serverRepository.findAllBetaTesters(); + List servers = serverBetaTesterRepository.findAllBetaTesters(); List results = new ArrayList<>(); boolean anyCleared = false; for (Server server : servers) { @@ -98,7 +100,7 @@ private List clearTenant(Server server) { log.error("Beta reset failed clearing {} for server {}", step.getKey(), server.getId(), e); } } - serverRepository.resetUsageAndStatsCounters(server.getId()); + serverUsageRepository.resetUsageAndStatsCounters(server.getId()); return cleared; } diff --git a/src/main/java/gg/modl/backend/beta/data/BetaAudit.java b/src/main/java/gg/modl/backend/beta/data/BetaAudit.java index 95ba075..21dd0de 100644 --- a/src/main/java/gg/modl/backend/beta/data/BetaAudit.java +++ b/src/main/java/gg/modl/backend/beta/data/BetaAudit.java @@ -2,12 +2,18 @@ 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; @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor @Document(collection = "beta_audit") @GenerateMongoFields public class BetaAudit { 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 83818cb..6616d68 100644 --- a/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java +++ b/src/main/java/gg/modl/backend/billing/config/StripeConfiguration.java @@ -1,13 +1,11 @@ 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; @@ -24,7 +22,7 @@ public class StripeConfiguration { @PostConstruct public void init() { - if (secretKey != null && !secretKey.isBlank()) { + if (isConfigured()) { Stripe.apiKey = secretKey; log.info("Stripe API initialized"); } else { @@ -32,14 +30,6 @@ public void init() { } } - @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 ab4d9dd..4df6f82 100644 --- a/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java +++ b/src/main/java/gg/modl/backend/billing/controller/PanelBillingController.java @@ -2,6 +2,7 @@ import gg.modl.backend.billing.service.BillingService; import gg.modl.backend.billing.service.UsageTrackingService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -28,6 +29,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_BILLING) +@RequiresPanelPermission(view = "admin.settings.view.billing", modify = "admin.settings.modify.billing") @RequiredArgsConstructor public class PanelBillingController { private final BillingService billingService; @@ -72,6 +74,7 @@ public ResponseEntity resubscribe(HttpServletRequest reques @GetMapping("/status") public ResponseEntity getBillingStatus(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); billingService.reconcileBillingStatus(server); return ResponseEntity.ok(PanelBillingProtoMapper.toBillingStatusResponse(billingService.getBillingStatus(server))); } @@ -79,6 +82,7 @@ public ResponseEntity getBillingStatus(HttpServletRequest @GetMapping("/usage") public ResponseEntity getUsage(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); + billingService.requireSuperAdmin(server, RequestUtil.getSessionEmail(request)); return ResponseEntity.ok(PanelBillingProtoMapper.toUsageResponse(usageTrackingService.getUsage(server))); } 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 c214e77..8a88833 100644 --- a/src/main/java/gg/modl/backend/billing/service/BillingService.java +++ b/src/main/java/gg/modl/backend/billing/service/BillingService.java @@ -50,8 +50,8 @@ public void syncCustomerEmail(Server server, String newEmail) { 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); + } catch (StripeException exception) { + log.warn("Failed to sync Stripe customer email for server {}; billing email may be stale", server.getId(), exception); } } @@ -67,8 +67,8 @@ public CheckoutSessionResponse createCheckoutSession(Server server) { 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); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to create checkout session", exception); } } @@ -80,8 +80,8 @@ public PortalSessionResponse createPortalSession(Server 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); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to create portal session", exception); } } @@ -111,8 +111,8 @@ public CancelResponse cancelSubscription(Server server) { "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); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to cancel subscription", exception); } } @@ -224,8 +224,8 @@ public ResubscribeResponse resubscribe(Server server) { periodEndDate ) ); - } catch (StripeException e) { - throw new ExternalServiceException("Failed to resubscribe", e); + } catch (StripeException exception) { + throw new ExternalServiceException("Failed to resubscribe", exception); } } 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 dd73016..823fa3b 100644 --- a/src/main/java/gg/modl/backend/billing/service/StripeService.java +++ b/src/main/java/gg/modl/backend/billing/service/StripeService.java @@ -15,6 +15,7 @@ import gg.modl.backend.infrastructure.config.ModlProperties; import gg.modl.backend.server.data.Server; import java.util.Date; +import java.util.function.Function; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -117,20 +118,19 @@ public Subscription createSubscription(String customerId) throws StripeException } 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; + return extractPeriod(subscription, SubscriptionItem::getCurrentPeriodStart); } public Date extractPeriodEnd(Subscription subscription) { + return extractPeriod(subscription, SubscriptionItem::getCurrentPeriodEnd); + } + + private Date extractPeriod(Subscription subscription, Function periodExtractor) { 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); + Long period = periodExtractor.apply(item); + if (period != null) { + return new Date(period * 1000); } } return null; 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 ec75325..d242301 100644 --- a/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java +++ b/src/main/java/gg/modl/backend/billing/service/StripeWebhookService.java @@ -5,8 +5,9 @@ import com.stripe.model.Invoice; import com.stripe.model.StripeObject; import com.stripe.model.Subscription; +import com.stripe.model.checkout.Session; import gg.modl.backend.database.mongo.repository.StripeWebhookEventMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.infrastructure.exception.ExternalServiceException; import gg.modl.backend.server.data.Server; import gg.modl.backend.server.data.ServerBillingUpdate; @@ -14,6 +15,7 @@ import gg.modl.backend.server.data.SubscriptionStatus; import gg.modl.backend.server.service.ServerMutationHelper; import java.util.Date; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -23,7 +25,7 @@ @Slf4j public class StripeWebhookService { private final StripeService stripeService; - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; private final UsageTrackingService usageTrackingService; private final ServerMutationHelper serverMutationHelper; private final StripeWebhookEventMongoRepository webhookEventRepository; @@ -50,26 +52,29 @@ public void processEvent(Event event) { } } - private void handleCheckoutCompleted(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof com.stripe.model.checkout.Session session)) { - return; - } + private Optional objectOf(Event event, Class type) { + return event.getDataObjectDeserializer().getObject() + .filter(type::isInstance) + .map(type::cast); + } - if (session.getCustomer() == null || session.getSubscription() == null) { - return; - } + private void handleCheckoutCompleted(Event event) { + objectOf(event, Session.class).ifPresent(session -> { + 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; - } + 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); + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(session.getSubscription()); + current.setSubscriptionStatus(SubscriptionStatus.ACTIVE); + current.setPlan(ServerPlan.PREMIUM); + }); }); } @@ -85,7 +90,7 @@ private void applyPeriodDates(ServerBillingUpdate current, Subscription subscrip } private Server findServerByCustomerId(String customerId) { - return serverRepository.findByStripeCustomerId(customerId).orElse(null); + return serverLookupRepository.findByStripeCustomerId(customerId).orElse(null); } private Server resolveServer(Subscription subscription) { @@ -105,25 +110,22 @@ private Server resolveServer(Subscription subscription) { } private void handleSubscriptionCreated(Event event) { - StripeObject stripeObject = event.getDataObjectDeserializer().getObject().orElse(null); - if (!(stripeObject instanceof Subscription subscription)) { - return; - } - - if (subscription.getCustomer() == null) { - return; - } + objectOf(event, Subscription.class).ifPresent(subscription -> { + if (subscription.getCustomer() == null) { + return; + } - Server server = findServerByCustomerId(subscription.getCustomer()); - if (server == 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); + serverMutationHelper.mutate(server, current -> { + current.setStripeSubscriptionId(subscription.getId()); + current.setSubscriptionStatus(SubscriptionStatus.fromStripeOrInactive(subscription.getStatus())); + current.setPlan(planForSubscriptionStatus(subscription.getStatus())); + applyPeriodDates(current, subscription); + }); }); } @@ -139,32 +141,29 @@ private boolean isFreeStatus(String 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); + objectOf(event, Subscription.class).ifPresent(subscription -> { + Server server = resolveServer(subscription); + if (server == null) { + log.warn("No server found for subscription: {}", subscription.getId()); + return; } - applyPeriodDates(current, subscription); + 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); + return serverLookupRepository.findByStripeSubscriptionId(subscriptionId).orElse(null); } private boolean isPremiumStatus(String status) { @@ -172,86 +171,85 @@ private boolean isPremiumStatus(String 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; - } + objectOf(event, Subscription.class).ifPresent(subscription -> { + Server server = resolveServer(subscription); + if (server == null) { + return; + } - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); - current.setPlan(ServerPlan.FREE); - current.setCurrentPeriodEnd(null); + serverMutationHelper.mutate(server, current -> { + current.setSubscriptionStatus(SubscriptionStatus.INACTIVE); + current.setPlan(ServerPlan.FREE); + current.setCurrentPeriodEnd(null); + }); + usageTrackingService.resetUsageCounters(server.getId()); }); - 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; - } + objectOf(event, Invoice.class).ifPresent(invoice -> { + if (invoice.getCustomer() == null) { + return; + } - Server server = findServerByCustomerId(invoice.getCustomer()); - if (server == null) { - return; - } + Server server = findServerByCustomerId(invoice.getCustomer()); + if (server == null) { + return; + } - serverMutationHelper.mutate(server, current -> { - current.setSubscriptionStatus(SubscriptionStatus.PAST_DUE); - current.setPlan(ServerPlan.FREE); + 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; - } + objectOf(event, Invoice.class).ifPresent(invoice -> { + if (invoice.getCustomer() == null) { + return; + } - Server server = findServerByCustomerId(invoice.getCustomer()); - if (server == null) { - return; - } + Server server = findServerByCustomerId(invoice.getCustomer()); + if (server == null) { + return; + } - String subscriptionId = extractInvoiceSubscriptionId(invoice); - if (subscriptionId == null) { - subscriptionId = server.getStripeSubscriptionId(); - } + String subscriptionId = extractInvoiceSubscriptionId(invoice); + if (subscriptionId == null) { + subscriptionId = server.getStripeSubscriptionId(); + } - if (subscriptionId == null) { - unstickPastDue(server); - return; - } + if (subscriptionId == null) { + unstickPastDue(server); + return; + } - boolean alreadyActive = server.getSubscriptionStatus() == SubscriptionStatus.ACTIVE - && server.getPlan() == ServerPlan.PREMIUM; - if (alreadyActive) { - 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); + 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); } - } catch (StripeException exception) { - throw new ExternalServiceException("Failed to sync subscription state on Stripe payment success", exception); - } + }); } private void unstickPastDue(Server server) { 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 f2e7f4a..c9f1da4 100644 --- a/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java +++ b/src/main/java/gg/modl/backend/billing/service/SubscriptionExpiryService.java @@ -1,6 +1,6 @@ package gg.modl.backend.billing.service; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.server.data.Server; import gg.modl.backend.server.data.ServerPlan; import gg.modl.backend.server.data.SubscriptionStatus; @@ -16,14 +16,14 @@ @RequiredArgsConstructor @Slf4j public class SubscriptionExpiryService { - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; private final UsageTrackingService usageTrackingService; private final ServerMutationHelper serverMutationHelper; @Scheduled(fixedRate = 3600000) public void checkExpiredSubscriptions() { try { - List cancelledServers = serverRepository.findCancelledWithPeriodEnd(); + List cancelledServers = serverLookupRepository.findCancelledWithPeriodEnd(); Date now = new Date(); for (Server server : cancelledServers) { 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 e8463fb..d1ffb87 100644 --- a/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java +++ b/src/main/java/gg/modl/backend/billing/service/UsageTrackingService.java @@ -2,7 +2,8 @@ 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.database.mongo.repository.ServerUsageRepository; +import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.limits.ServerLimitPolicy; import gg.modl.backend.server.data.Server; @@ -18,26 +19,27 @@ @RequiredArgsConstructor @Slf4j public class UsageTrackingService { - private final ServerMongoRepository serverRepository; + private final ServerUsageRepository serverUsageRepository; 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; + private static final long DEFAULT_PERIOD_MILLIS = 30L * 24 * 60 * 60 * 1000; public UsageResponse getUsage(Server server) { Server freshServer = getFreshServer(server.getId()); if (freshServer == null) { - throw new IllegalStateException("Server not found in database."); + throw new ResourceNotFoundException("Server not found in database."); } Date currentPeriodStart = freshServer.getCurrentPeriodStart(); if (currentPeriodStart == null) { - currentPeriodStart = new Date(System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)); + currentPeriodStart = new Date(System.currentTimeMillis() - DEFAULT_PERIOD_MILLIS); } Date currentPeriodEnd = freshServer.getCurrentPeriodEnd(); if (currentPeriodEnd == null) { - currentPeriodEnd = new Date(System.currentTimeMillis() + (30L * 24 * 60 * 60 * 1000)); + currentPeriodEnd = new Date(System.currentTimeMillis() + DEFAULT_PERIOD_MILLIS); } long aiRequestsUsed = freshServer.getAiRequestsCurrentPeriod() != null ? freshServer.getAiRequestsCurrentPeriod() : 0L; @@ -71,12 +73,12 @@ public long getAiBaseLimitRequests() { } private Server getFreshServer(String serverId) { - return serverRepository.findById(serverId).orElse(null); + return serverUsageRepository.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."); + throw new ResourceNotFoundException("No Stripe customer ID found. Please ensure you have an active subscription."); } serverMutationHelper.mutate(server, current -> { @@ -92,11 +94,11 @@ public UsageBillingSettingsResponse updateUsageBillingSettings(Server server, bo } public void incrementAiRequests(String serverId, long additionalRequests) { - serverRepository.incrementAiRequests(serverId, additionalRequests); + serverUsageRepository.incrementAiRequests(serverId, additionalRequests); } public void resetUsageCounters(String serverId) { - serverRepository.resetUsageCounters(serverId); + serverUsageRepository.resetUsageCounters(serverId); } public void updateStorageLimit(Server server, long bytes) { 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 6e34423..40e7311 100644 --- a/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java +++ b/src/main/java/gg/modl/backend/cloudflare/external/CloudflareClient.java @@ -5,6 +5,7 @@ import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -17,9 +18,11 @@ @RequiredArgsConstructor @Slf4j public class CloudflareClient { + private static final String CLOUDFLARE_API = "https://api.cloudflare.com/client/v4"; + private static final ParameterizedTypeReference> MAP_RESPONSE = + new ParameterizedTypeReference<>() {}; 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()) { @@ -43,16 +46,16 @@ public CustomHostnameResult createCustomHostname(String hostname) { HttpEntity> request = new HttpEntity<>(body, headers); String url = CLOUDFLARE_API + "/zones/" + config.getZoneId() + "/custom_hostnames"; - ResponseEntity response = restTemplate.postForEntity(url, request, Map.class); + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.POST, request, MAP_RESPONSE); 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); + Object errors = response.getBody().get("errors"); + if (errors instanceof List errorList && !errorList.isEmpty()) { + log.error("Cloudflare API error creating custom hostname: {}", errorList); } } } @@ -63,9 +66,8 @@ public CustomHostnameResult createCustomHostname(String hostname) { } } - @SuppressWarnings("unchecked") private CustomHostnameResult parseCustomHostnameResult(Map responseBody) { - Map result = (Map) responseBody.get("result"); + Map result = (Map) responseBody.get("result"); if (result == null) { return null; } @@ -75,7 +77,7 @@ private CustomHostnameResult parseCustomHostnameResult(Map respo String status = (String) result.get("status"); CustomHostnameResult.SslStatus sslStatus = null; - Map ssl = (Map) result.get("ssl"); + Map ssl = (Map) result.get("ssl"); if (ssl != null) { sslStatus = new CustomHostnameResult.SslStatus( (String) ssl.get("status"), @@ -86,7 +88,7 @@ private CustomHostnameResult parseCustomHostnameResult(Map respo String ownershipHttpUrl = null; String ownershipHttpBody = null; - Map ownershipVerification = (Map) result.get("ownership_verification"); + Map ownershipVerification = (Map) result.get("ownership_verification"); if (ownershipVerification != null) { ownershipHttpUrl = (String) ownershipVerification.get("http_url"); ownershipHttpBody = (String) ownershipVerification.get("http_body"); @@ -119,7 +121,7 @@ public CustomHostnameResult getCustomHostname(String hostnameId) { 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); + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.GET, request, MAP_RESPONSE); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { Boolean success = (Boolean) response.getBody().get("success"); @@ -144,13 +146,13 @@ public CustomHostnameResult findCustomHostnameByName(String hostname) { 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); + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.GET, request, MAP_RESPONSE); 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()) { + Object resultsValue = response.getBody().get("result"); + if (resultsValue instanceof List results && !results.isEmpty()) { Map modifiedBody = Map.of( "success", true, "result", results.get(0) @@ -176,7 +178,7 @@ public boolean deleteCustomHostname(String hostnameId) { 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); + ResponseEntity> response = restTemplate.exchange(url, HttpMethod.DELETE, request, MAP_RESPONSE); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { return Boolean.TRUE.equals(response.getBody().get("success")); 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 46a53ed..360b984 100644 --- a/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java +++ b/src/main/java/gg/modl/backend/dashboard/controller/DashboardController.java @@ -7,6 +7,7 @@ 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.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -28,6 +29,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_DASHBOARD) +@RequiresPanelPermission("admin.audit.view.dashboard") @RequiredArgsConstructor @Validated public class DashboardController { 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 c81f83c..f89f442 100644 --- a/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java +++ b/src/main/java/gg/modl/backend/dashboard/service/DashboardService.java @@ -223,9 +223,9 @@ public List getRecentPunishments(Server server, int li continue; } - String reason = ""; - if (punishment.getData() != null && punishment.getData().get("reason") != null) { - reason = String.valueOf(punishment.getData().get("reason")); + String reason = punishment.data().reason(); + if (reason == null) { + reason = ""; } String typeName = punishmentTypeNameByOrdinal.getOrDefault(punishment.getTypeOrdinal(), "Unknown"); diff --git a/src/main/java/gg/modl/backend/database/CollectionName.java b/src/main/java/gg/modl/backend/database/CollectionName.java index 0595926..724b48d 100644 --- a/src/main/java/gg/modl/backend/database/CollectionName.java +++ b/src/main/java/gg/modl/backend/database/CollectionName.java @@ -1,7 +1,6 @@ package gg.modl.backend.database; public final class CollectionName { - // Global public static final String MODL_SERVERS = "servers"; public static final String METRIC_SNAPSHOTS = "metric_snapshots"; public static final String SERVER_INSTANCE_SNAPSHOTS = "server_instance_snapshots"; @@ -9,52 +8,45 @@ public final class CollectionName { public static final String BETA_AUDIT = "beta_audit"; public static final String SCHEDULER_LEASES = "scheduler_leases"; - // Core public static final String PLAYERS = "players"; public static final String SESSIONS = "sessions"; public static final String AUTH_CODES = "auth_codes"; public static final String SETTINGS = "settings"; - // Staff & Roles public static final String STAFF = "staffs"; public static final String STAFF_ROLES = "staffroles"; public static final String INVITATIONS = "invitations"; - // Tickets public static final String TICKETS = "tickets"; public static final String TICKET_VERIFICATIONS = "ticket_verifications"; public static final String CHAT_LOGS = "chat_logs"; public static final String COMMAND_LOGS = "command_logs"; - // Audit public static final String LOGS = "logs"; - // WebAuthn public static final String WEBAUTHN_CREDENTIALS = "webauthn_credentials"; public static final String WEBAUTHN_CHALLENGES = "webauthn_challenges"; - // Knowledgebase & Homepage public static final String KNOWLEDGEBASE_CATEGORIES = "knowledgebasecategories"; public static final String KNOWLEDGEBASE_ARTICLES = "knowledgebasearticles"; public static final String HOMEPAGE_CARDS = "homepagecards"; - // System public static final String SYSTEM_LOGS = "system_logs"; public static final String SYSTEM_ALERTS = "system_alerts"; public static final String SECURITY_EVENTS = "security_events"; public static final String MIGRATIONS = "migrations"; public static final String TENANT_MIGRATIONS = "tenant_migrations"; + public static final String ADMIN_USERS = "admin_users"; + public static final String SYSTEM_CONFIG = "system_config"; + public static final String SYSTEM_PROMPTS = "systemprompts"; - // Replays public static final String REPLAYS = "replays"; public static final String REPLAY_LITE_REPLAYS = "replay_lite_replays"; public static final String REPLAY_LITE_DAILY_QUOTAS = "replay_lite_daily_quotas"; - // Training public static final String TRAINING_DATABASE = "training_data"; public static final String TRAINING_SEGMENTS = "training_segments"; - // Storage public static final String STORAGE_FILES = "storage_files"; public static final String EVIDENCE_UPLOAD_TOKENS = "evidence_upload_tokens"; } diff --git a/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java b/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java index 1bdd018..ed56fd9 100644 --- a/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java +++ b/src/main/java/gg/modl/backend/database/DynamicMongoTemplateProvider.java @@ -4,12 +4,14 @@ import com.github.benmanes.caffeine.cache.Caffeine; import com.mongodb.client.MongoClient; import java.time.Duration; +import lombok.RequiredArgsConstructor; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; import org.springframework.data.mongodb.core.convert.MappingMongoConverter; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class DynamicMongoTemplateProvider { private final MongoClient mongoClient; private final MappingMongoConverter mongoConverter; @@ -19,14 +21,6 @@ public class DynamicMongoTemplateProvider { .build(); private static final String GLOBAL_DATABASE_NAME = "modl"; - public DynamicMongoTemplateProvider( - MongoClient mongoClient, - MappingMongoConverter mongoConverter - ) { - this.mongoClient = mongoClient; - this.mongoConverter = mongoConverter; - } - public MongoTemplate getGlobalDatabase() { return getFromDatabaseName(GLOBAL_DATABASE_NAME); } diff --git a/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java b/src/main/java/gg/modl/backend/database/MongoIndexReconciler.java similarity index 63% rename from src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java rename to src/main/java/gg/modl/backend/database/MongoIndexReconciler.java index 01409f0..79a5fb7 100644 --- a/src/main/java/gg/modl/backend/database/MongoIndexBootstrapService.java +++ b/src/main/java/gg/modl/backend/database/MongoIndexReconciler.java @@ -1,26 +1,48 @@ package gg.modl.backend.database; import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.database.mongo.fields.AdminUserFields; +import gg.modl.backend.database.mongo.fields.AuthCodeFields; +import gg.modl.backend.database.mongo.fields.AuthSessionDataFields; +import gg.modl.backend.database.mongo.fields.BetaAuditFields; +import gg.modl.backend.database.mongo.fields.ChatLogFields; +import gg.modl.backend.database.mongo.fields.CommandLogFields; +import gg.modl.backend.database.mongo.fields.EvidenceUploadTokenDocumentFields; +import gg.modl.backend.database.mongo.fields.HomepageCardFields; +import gg.modl.backend.database.mongo.fields.InvitationFields; +import gg.modl.backend.database.mongo.fields.KnowledgebaseArticleFields; +import gg.modl.backend.database.mongo.fields.KnowledgebaseCategoryFields; +import gg.modl.backend.database.mongo.fields.MetricSnapshotFields; +import gg.modl.backend.database.mongo.fields.MigrationStatusFields; +import gg.modl.backend.database.mongo.fields.PlayerFields; +import gg.modl.backend.database.mongo.fields.ReplayDocumentFields; +import gg.modl.backend.database.mongo.fields.ReplayLiteDailyQuotaDocumentFields; +import gg.modl.backend.database.mongo.fields.ReplayLiteDocumentFields; +import gg.modl.backend.database.mongo.fields.SecurityEventFields; import gg.modl.backend.database.mongo.fields.ServerFields; +import gg.modl.backend.database.mongo.fields.ServerInstanceSnapshotFields; +import gg.modl.backend.database.mongo.fields.ServerLogFields; +import gg.modl.backend.database.mongo.fields.SettingsFields; +import gg.modl.backend.database.mongo.fields.StaffFields; +import gg.modl.backend.database.mongo.fields.StaffRoleFields; +import gg.modl.backend.database.mongo.fields.StorageFileDocumentFields; +import gg.modl.backend.database.mongo.fields.SystemAlertFields; +import gg.modl.backend.database.mongo.fields.SystemLogFields; +import gg.modl.backend.database.mongo.fields.TicketFields; +import gg.modl.backend.database.mongo.fields.TicketVerificationFields; +import gg.modl.backend.database.mongo.fields.TrainingSegmentDocumentFields; +import gg.modl.backend.database.mongo.fields.WebAuthnChallengeFields; +import gg.modl.backend.database.mongo.fields.WebAuthnCredentialFields; 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; @@ -29,13 +51,12 @@ 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 { +public class MongoIndexReconciler { private static final String TYPE_OPERATOR = "$type"; private static final Map BSON_TYPE_CODES = Map.ofEntries( Map.entry("double", 1), @@ -57,13 +78,7 @@ public class MongoIndexBootstrapService { 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() { @@ -83,313 +98,253 @@ 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), + doc(TrainingSegmentDocumentFields.SERVER_DATABASE_NAME, 1).append(TrainingSegmentDocumentFields.REPLAY_ID, 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("uidx_servers_serverName", doc(ServerFields.SERVER_NAME, 1), true, false), + IndexSpec.standard("uidx_servers_customDomain", doc(ServerFields.CUSTOM_DOMAIN, 1), true, false), + IndexSpec.standard("uidx_servers_adminEmail", doc(ServerFields.ADMIN_EMAIL, 1), true, false), + IndexSpec.standard("idx_servers_emailVerified", doc(ServerFields.EMAIL_VERIFIED, 1), false, false), + IndexSpec.standard("uidx_servers_emailVerificationToken", doc(ServerFields.EMAIL_VERIFICATION_TOKEN, 1), true, true), + IndexSpec.standard("idx_servers_provisioningStatus", doc(ServerFields.PROVISIONING_STATUS, 1), false, false), IndexSpec.standard( "idx_servers_registration_cleanup", - doc("emailVerified", 1).append("provisioningStatus", 1).append("createdAt", 1).append("emailVerificationToken", 1), + doc(ServerFields.EMAIL_VERIFIED, 1) + .append(ServerFields.PROVISIONING_STATUS, 1) + .append(ServerFields.CREATED_AT, 1) + .append(ServerFields.EMAIL_VERIFICATION_TOKEN, 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) + IndexSpec.standard("uidx_servers_provisioningSignInToken", doc(ServerFields.PROVISIONING_SIGN_IN_TOKEN, 1), true, true), + IndexSpec.standard("uidx_servers_stripeCustomerId", doc(ServerFields.STRIPE_CUSTOMER_ID, 1), true, true), + IndexSpec.standard("uidx_servers_stripeSubscriptionId", doc(ServerFields.STRIPE_SUBSCRIPTION_ID, 1), true, true), + IndexSpec.standard("uidx_servers_customDomainOverride", doc(ServerFields.CUSTOM_DOMAIN_OVERRIDE, 1), true, true), + IndexSpec.standard("uidx_servers_customDomainCloudflareId", doc(ServerFields.CUSTOM_DOMAIN_CLOUDFLARE_ID, 1), true, true), + IndexSpec.standard("uidx_servers_cliSetupToken", doc(ServerFields.CLI_SETUP_TOKEN, 1), true, true), + IndexSpec.standard("uidx_servers_apiKey", doc(ServerFields.API_KEY, 1), true, true), + IndexSpec.standard("idx_servers_userCount", doc(ServerFields.USER_COUNT, 1), false, false), + IndexSpec.standard("idx_servers_ticketCount", doc(ServerFields.TICKET_COUNT, 1), false, false), + IndexSpec.standard("idx_servers_lastStatsUpdatedAt", doc(ServerFields.LAST_STATS_UPDATED_AT, 1), false, false), + IndexSpec.standard("idx_servers_createdAt", doc(ServerFields.CREATED_AT, 1), false, false), + IndexSpec.standard("idx_servers_lastActivityAt", doc(ServerFields.LAST_ACTIVITY_AT, -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) + IndexSpec.standard("uidx_metric_snapshots_date", doc(MetricSnapshotFields.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("uidx_replay_lite_objectKey", doc(ReplayLiteDocumentFields.OBJECT_KEY, 1), true, false), + IndexSpec.standard("idx_replay_lite_expiresAt", doc(ReplayLiteDocumentFields.EXPIRES_AT, 1), false, true), IndexSpec.standard( "idx_replay_lite_server_status_confirmedAt", - doc("pluginServerUuid", 1).append("status", 1).append("confirmedAt", 1), + doc(ReplayLiteDocumentFields.PLUGIN_SERVER_UUID, 1) + .append(ReplayLiteDocumentFields.STATUS, 1) + .append(ReplayLiteDocumentFields.CONFIRMED_AT, 1), false, false ), IndexSpec.standard( "idx_replay_lite_server_status_createdAt", - doc("pluginServerUuid", 1).append("status", 1).append("createdAt", 1), + doc(ReplayLiteDocumentFields.PLUGIN_SERVER_UUID, 1) + .append(ReplayLiteDocumentFields.STATUS, 1) + .append(ReplayLiteDocumentFields.CREATED_AT, 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) + IndexSpec.standard("idx_replay_lite_status_createdAt", + doc(ReplayLiteDocumentFields.STATUS, 1).append(ReplayLiteDocumentFields.CREATED_AT, 1).append(ReplayLiteDocumentFields.ID, 1), + false, false), + IndexSpec.standard("idx_replay_lite_status_expiresAt", + doc(ReplayLiteDocumentFields.STATUS, 1).append(ReplayLiteDocumentFields.EXPIRES_AT, 1).append(ReplayLiteDocumentFields.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), + doc(ReplayLiteDailyQuotaDocumentFields.PLUGIN_SERVER_UUID, 1).append(ReplayLiteDailyQuotaDocumentFields.DAY, 1), true, false ), - IndexSpec.ttl("idx_replay_lite_daily_quotas_expiresAt_ttl", doc("expiresAt", 1), 0) + IndexSpec.ttl("idx_replay_lite_daily_quotas_expiresAt_ttl", doc(ReplayLiteDailyQuotaDocumentFields.EXPIRES_AT, 1), 0) )); - ensureIndexes(template, "admin_users", List.of( - IndexSpec.standard("uidx_admin_users_email", doc("email", 1), true, false) + ensureIndexes(template, CollectionName.ADMIN_USERS, List.of( + IndexSpec.standard("uidx_admin_users_email", doc(AdminUserFields.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) + IndexSpec.standard("idx_system_alerts_expiresAt", doc(SystemAlertFields.EXPIRES_AT, 1), false, false), + IndexSpec.standard("idx_system_alerts_createdAt", doc(SystemAlertFields.CREATED_AT, -1), false, false), + IndexSpec.standard("idx_system_alerts_audience_expiresAt", doc(SystemAlertFields.AUDIENCE, 1).append(SystemAlertFields.EXPIRES_AT, 1), false, false) )); ensureIndexes(template, CollectionName.SERVER_INSTANCE_SNAPSHOTS, List.of( - IndexSpec.standard("uidx_server_instance_snapshots_date", doc("date", 1), true, false) + IndexSpec.standard("uidx_server_instance_snapshots_date", doc(ServerInstanceSnapshotFields.DATE, 1), true, false) )); ensureIndexes(template, CollectionName.EVIDENCE_UPLOAD_TOKENS, List.of( - IndexSpec.ttl("idx_evidence_upload_tokens_expiresAt_ttl", doc("expiresAt", 1), 0) + IndexSpec.ttl("idx_evidence_upload_tokens_expiresAt_ttl", doc(EvidenceUploadTokenDocumentFields.EXPIRES_AT, 1), 0) )); ensureIndexes(template, CollectionName.BETA_AUDIT, List.of( - IndexSpec.standard("idx_beta_audit_serverId_timestamp", doc("serverId", 1).append("timestamp", -1), false, false) + IndexSpec.standard("idx_beta_audit_serverId_timestamp", doc(BetaAuditFields.SERVER_ID, 1).append(BetaAuditFields.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) + IndexSpec.standard("uidx_settings_type", doc(SettingsFields.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.partialUnique("uidx_players_minecraftUuid", doc(PlayerFields.MINECRAFT_UUID, 1), + new Document(PlayerFields.MINECRAFT_UUID, new Document(TYPE_OPERATOR, "string"))), + IndexSpec.standard("idx_players_punishments_issued_desc", doc(PlayerFields.PUNISHMENT_ISSUED, -1), false, false), IndexSpec.standard( "idx_players_punishments_issuerName_issued_desc", - doc("punishments.issuerName", 1).append("punishments.issued", -1), + doc(PlayerFields.PUNISHMENT_ISSUER_NAME, 1).append(PlayerFields.PUNISHMENT_ISSUED, -1), false, false ), IndexSpec.standard( "idx_players_punishments_issuerId_issued_desc", - doc("punishments.issuerId", 1).append("punishments.issued", -1), + doc(PlayerFields.PUNISHMENT_ISSUER_ID, 1).append(PlayerFields.PUNISHMENT_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), + IndexSpec.standard("idx_players_ipAddresses_ipAddress", doc(PlayerFields.IP_ADDRESS, 1), false, false), + IndexSpec.standard("idx_players_usernames_username", doc(PlayerFields.USERNAME, 1), false, false), + IndexSpec.collated("idx_players_usernames_username_ci", doc(PlayerFields.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) + IndexSpec.standard("idx_players_punishments_id", doc(PlayerFields.PUNISHMENT_ID, 1), false, true), + IndexSpec.standard("idx_players_data_isOnline", doc(PlayerFields.DATA_IS_ONLINE, 1), false, true), + IndexSpec.standard("idx_players_ipAddresses_firstLogin", doc(PlayerFields.IP_FIRST_LOGIN, -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) + IndexSpec.standard("uidx_staff_email", doc(StaffFields.EMAIL, 1), true, false), + IndexSpec.standard("uidx_staff_username", doc(StaffFields.USERNAME, 1), true, false), + IndexSpec.standard("sidx_staff_assignedMinecraftUuid", doc(StaffFields.ASSIGNED_MINECRAFT_UUID, 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) + IndexSpec.standard("uidx_staff_roles_name", doc(StaffRoleFields.NAME, 1), true, false), + IndexSpec.standard("idx_staff_roles_order", doc(StaffRoleFields.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) + IndexSpec.standard("idx_invitations_email", doc(InvitationFields.EMAIL, 1), false, false), + IndexSpec.standard("uidx_invitations_token", doc(InvitationFields.TOKEN, 1), true, false), + IndexSpec.ttl("idx_invitations_expiresAt_ttl", doc(InvitationFields.EXPIRES_AT, 1), 0) )); ensureIndexes(template, CollectionName.TICKET_VERIFICATIONS, List.of( - IndexSpec.ttl("idx_ticket_verifications_expiresAt_ttl", doc("expiresAt", 1), 0) + IndexSpec.ttl("idx_ticket_verifications_expiresAt_ttl", doc(TicketVerificationFields.EXPIRES_AT, 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) + IndexSpec.standard("idx_tickets_status_created", doc(TicketFields.STATUS, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_created", doc(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_updatedAt", doc(TicketFields.UPDATED_AT, -1), false, false), + IndexSpec.standard("idx_tickets_type_created", doc(TicketFields.TYPE, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_creatorUuid_created", doc(TicketFields.CREATOR_UUID, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_reportedPlayerUuid_created", doc(TicketFields.REPORTED_PLAYER_UUID, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_locked_created", doc(TicketFields.LOCKED, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_assignedTo_updatedAt", doc(TicketFields.ASSIGNED_TO, 1).append(TicketFields.UPDATED_AT, -1), false, false), + IndexSpec.standard("idx_tickets_creatorName_created", doc(TicketFields.CREATOR_NAME, 1).append(TicketFields.CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_replies_name_created", doc(TicketFields.REPLY_NAME, 1).append(TicketFields.REPLY_CREATED, -1), false, false), + IndexSpec.standard("idx_tickets_tags", doc(TicketFields.TAGS, 1), false, false), + IndexSpec.standard("idx_tickets_replayUrl", doc(TicketFields.REPLAY_URL, 1), false, true), + IndexSpec.standard("idx_tickets_replayId", doc(TicketFields.REPLAY_ID, 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) + IndexSpec.standard("idx_replays_targetUuid_createdAt", doc(ReplayDocumentFields.TARGET_UUID, 1).append(ReplayDocumentFields.CREATED_AT, -1), false, true), + IndexSpec.standard("idx_replays_status_createdAt", + doc(ReplayDocumentFields.STATUS, 1).append(ReplayDocumentFields.CREATED_AT, 1).append(ReplayDocumentFields.ID, 1), false, false), + IndexSpec.standard("idx_replays_storageKey", doc(ReplayDocumentFields.STORAGE_KEY, 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) + IndexSpec.standard("uidx_storage_files_key", doc(StorageFileDocumentFields.KEY, 1), true, false), + IndexSpec.standard("idx_storage_files_key_createdAt", doc(StorageFileDocumentFields.KEY, 1).append(StorageFileDocumentFields.CREATED_AT, -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) + IndexSpec.standard("uidx_knowledgebase_categories_slug", doc(KnowledgebaseCategoryFields.SLUG, 1), true, false), + IndexSpec.standard("idx_knowledgebase_categories_name", doc(KnowledgebaseCategoryFields.NAME, 1), false, false), + IndexSpec.standard("idx_knowledgebase_categories_ordinal", doc(KnowledgebaseCategoryFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_knowledgebase_categories_isVisible_ordinal", doc(KnowledgebaseCategoryFields.IS_VISIBLE, 1).append(KnowledgebaseCategoryFields.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), + IndexSpec.standard("uidx_knowledgebase_articles_slug", doc(KnowledgebaseArticleFields.SLUG, 1), true, false), + IndexSpec.standard("idx_knowledgebase_articles_categoryId_ordinal", doc(KnowledgebaseArticleFields.CATEGORY_ID, 1).append(KnowledgebaseArticleFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_knowledgebase_articles_isVisible_categoryId_ordinal", + doc(KnowledgebaseArticleFields.IS_VISIBLE, 1).append(KnowledgebaseArticleFields.CATEGORY_ID, 1).append(KnowledgebaseArticleFields.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) + IndexSpec.standard("idx_webauthn_credentials_email", doc(WebAuthnCredentialFields.EMAIL, 1), false, false), + IndexSpec.standard("uidx_webauthn_credentials_credentialId", doc(WebAuthnCredentialFields.CREDENTIAL_ID, 1), true, false), + IndexSpec.standard("idx_webauthn_credentials_userHandle", doc(WebAuthnCredentialFields.USER_HANDLE, 1), false, false) )); ensureIndexes(template, CollectionName.WEBAUTHN_CHALLENGES, List.of( - IndexSpec.ttl("idx_webauthn_challenges_expiresAt_ttl", doc("expiresAt", 1), 0) + IndexSpec.ttl("idx_webauthn_challenges_expiresAt_ttl", doc(WebAuthnChallengeFields.EXPIRES_AT, 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) + IndexSpec.standard("idx_homepage_cards_ordinal", doc(HomepageCardFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_homepage_cards_isEnabled_ordinal", doc(HomepageCardFields.IS_ENABLED, 1).append(HomepageCardFields.ORDINAL, 1), false, false), + IndexSpec.standard("idx_homepage_cards_categoryId", doc(HomepageCardFields.CATEGORY_ID, 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) + IndexSpec.standard("idx_sessions_email", doc(AuthSessionDataFields.EMAIL, 1), false, false), + IndexSpec.ttl("idx_sessions_expiresAt_ttl", doc(AuthSessionDataFields.EXPIRES_AT, 1), 0) )); ensureIndexes(template, CollectionName.AUTH_CODES, List.of( - IndexSpec.ttl("idx_auth_codes_expiresAt_ttl", doc("expiresAt", 1), 0) + IndexSpec.ttl("idx_auth_codes_expiresAt_ttl", doc(AuthCodeFields.EXPIRES_AT, 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) + IndexSpec.standard("idx_system_logs_timestamp", doc(SystemLogFields.TIMESTAMP, -1), false, false), + IndexSpec.standard("idx_system_logs_level_timestamp", doc(SystemLogFields.LEVEL, 1).append(SystemLogFields.TIMESTAMP, -1), false, false), + IndexSpec.standard("idx_system_logs_source_timestamp", doc(SystemLogFields.SOURCE, 1).append(SystemLogFields.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) + IndexSpec.standard("idx_security_events_timestamp", doc(SecurityEventFields.TIMESTAMP, -1), false, false), + IndexSpec.standard("idx_security_events_severity_timestamp", doc(SecurityEventFields.SEVERITY, 1).append(SecurityEventFields.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) + IndexSpec.standard("idx_chat_logs_uuid_timestamp", doc(ChatLogFields.UUID, 1).append(ChatLogFields.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) + IndexSpec.standard("idx_command_logs_uuid_timestamp", doc(CommandLogFields.UUID, 1).append(CommandLogFields.TIMESTAMP, -1), false, false) )); ensureIndexes(template, CollectionName.LOGS, List.of( - IndexSpec.standard("idx_logs_created_desc", doc("created", -1), false, false) + IndexSpec.standard("idx_logs_created_desc", doc(ServerLogFields.CREATED, -1), false, false) )); ensureIndexes(template, CollectionName.MIGRATIONS, List.of( - IndexSpec.standard("idx_migrations_status_startedAt", doc("status", 1).append("startedAt", -1), false, false) + IndexSpec.standard("idx_migrations_status_startedAt", doc(MigrationStatusFields.STATUS, 1).append(MigrationStatusFields.STARTED_AT, -1), false, false) )); } @@ -592,9 +547,6 @@ 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, diff --git a/src/main/java/gg/modl/backend/database/TenantMigrationService.java b/src/main/java/gg/modl/backend/database/TenantMigrationService.java index 62c73a5..bc2cb41 100644 --- a/src/main/java/gg/modl/backend/database/TenantMigrationService.java +++ b/src/main/java/gg/modl/backend/database/TenantMigrationService.java @@ -12,8 +12,10 @@ import com.mongodb.client.model.Updates; import com.mongodb.client.result.UpdateResult; import gg.modl.backend.database.mongo.fields.AuditLogFields; +import gg.modl.backend.database.mongo.fields.InvitationFields; import gg.modl.backend.database.mongo.fields.PlayerFields; import gg.modl.backend.database.mongo.fields.SettingsFields; +import gg.modl.backend.database.mongo.fields.StaffFields; import gg.modl.backend.database.mongo.fields.StaffRoleFields; import gg.modl.backend.database.mongo.fields.TicketFields; import gg.modl.backend.player.data.Player; @@ -53,7 +55,6 @@ public class TenantMigrationService { static final String NORMALIZE_PLAYER_IDS_MIGRATION_ID = "normalize-player-ids"; static final String BACKFILL_TICKET_REPLAY_ID_MIGRATION_ID = "backfill-ticket-replay-id"; private static final Pattern UPPERCASE_HEX_PATTERN = Pattern.compile("[A-F]"); - private static final String ROLE_FIELD = "role"; private static final String ID_FIELD = "_id"; private static final String LOG_METADATA_PLAYER_ID = AuditLogFields.METADATA + ".playerId"; private static final String STATUS_FIELD = "status"; @@ -224,12 +225,12 @@ private void backfillStaffRoleIds(MongoTemplate template) { name, template.getDb().getName()); continue; } - staffUpdated += staff.updateMany(Filters.eq(ROLE_FIELD, name), Updates.set(ROLE_FIELD, id)).getModifiedCount(); - invitationsUpdated += invitations.updateMany(Filters.eq(ROLE_FIELD, name), Updates.set(ROLE_FIELD, id)).getModifiedCount(); + staffUpdated += staff.updateMany(Filters.eq(StaffFields.ROLE_ID, name), Updates.set(StaffFields.ROLE_ID, id)).getModifiedCount(); + invitationsUpdated += invitations.updateMany(Filters.eq(InvitationFields.ROLE_ID, name), Updates.set(InvitationFields.ROLE_ID, id)).getModifiedCount(); } - long staffOrphans = staff.countDocuments(Filters.nin(ROLE_FIELD, roleIds)); - long invitationOrphans = invitations.countDocuments(Filters.nin(ROLE_FIELD, roleIds)); + long staffOrphans = staff.countDocuments(Filters.nin(StaffFields.ROLE_ID, roleIds)); + long invitationOrphans = invitations.countDocuments(Filters.nin(InvitationFields.ROLE_ID, roleIds)); if (staffOrphans > 0 || invitationOrphans > 0) { log.warn("Role-id backfill left unresolved role references in database={} staffOrphans={} invitationOrphans={}", template.getDb().getName(), staffOrphans, invitationOrphans); diff --git a/src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java b/src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java new file mode 100644 index 0000000..a252a17 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/TenantSchemaBootstrapper.java @@ -0,0 +1,110 @@ +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.scheduling.SchedulerLeaseService; +import java.time.Duration; +import java.util.List; +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.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TenantSchemaBootstrapper { + 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 MongoIndexReconciler mongoIndexReconciler; + private final SchedulerLeaseService schedulerLeaseService; + + @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); + mongoIndexReconciler.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; + }; + } + + record BootstrapTarget(@Id String id, String databaseName) { + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java index 95408b2..579addc 100644 --- a/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/AbstractServerMongoRepository.java @@ -6,10 +6,13 @@ import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.function.BiFunction; +import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; 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; @@ -53,6 +56,10 @@ public T saveEntity(Server server, T entity) { return save(tenantMongoAccess.forServer(server), entity); } + public T insertEntity(Server server, T entity) { + return insert(tenantMongoAccess.forServer(server), entity); + } + public void insertAll(Server server, Collection entities) { insertAll(tenantMongoAccess.forServer(server), entities); } @@ -85,6 +92,27 @@ public AggregationResults aggregate(Server server, Aggregation aggregatio return aggregate(tenantMongoAccess.forServer(server), aggregation, outputType); } + protected void bulkUpdateByIds(Server server, List ids, BiFunction updateFactory) { + bulkUpdateByIds(server, ids, (id, index) -> Criteria.where("_id").is(id), updateFactory); + } + + protected void bulkUpdateByIds( + Server server, + List ids, + BiFunction criteriaFactory, + BiFunction updateFactory + ) { + if (ids.isEmpty()) { + return; + } + BulkOperations bulk = serverTemplate(server).bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); + for (int index = 0; index < ids.size(); index++) { + String id = ids.get(index); + bulk.updateOne(Query.query(criteriaFactory.apply(id, index)), updateFactory.apply(id, index)); + } + bulk.execute(); + } + protected MongoTemplate serverTemplate(Server server) { return tenantMongoAccess.forServer(server); } diff --git a/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java index b12109b..f503169 100644 --- a/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/AbstractTenantMongoRepository.java @@ -94,10 +94,6 @@ protected AggregationResults aggregate(MongoTemplate template, Aggregatio return template.aggregate(aggregation, collectionName, outputType); } - protected MongoTemplate rawTemplate(MongoTemplate template) { - return template; - } - protected Class entityType() { return entityType; } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java new file mode 100644 index 0000000..2950de7 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AdminDatabaseBrowserRepository.java @@ -0,0 +1,28 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.database.mongo.TenantMongoAccess; +import gg.modl.backend.server.data.Server; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class AdminDatabaseBrowserRepository { + private final TenantMongoAccess tenantMongoAccess; + + public List readTable(Server server, String collectionName, int limit, int skip) { + Query query = new Query() + .with(Sort.by(Sort.Direction.DESC, "_id")) + .skip(skip) + .limit(limit); + return tenantMongoAccess.forServer(server).find(query, Document.class, collectionName); + } + + public long countCollection(Server server, String collectionName) { + return tenantMongoAccess.forServer(server).count(new Query(), collectionName); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java index c352cdf..736aeac 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AdminUserMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.AdminUser; +import gg.modl.backend.database.CollectionName; import gg.modl.backend.database.mongo.AbstractGlobalMongoRepository; import gg.modl.backend.email.EmailAddressUtil; import gg.modl.backend.database.mongo.TenantMongoAccess; @@ -15,10 +16,9 @@ @Repository public class AdminUserMongoRepository extends AbstractGlobalMongoRepository { - private static final String COLLECTION_NAME = "admin_users"; public AdminUserMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(AdminUser.class, COLLECTION_NAME, tenantMongoAccess); + super(AdminUser.class, CollectionName.ADMIN_USERS, tenantMongoAccess); } public Optional findByEmailIgnoreCase(String email) { 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 3c6b62b..bc88fc5 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 @@ -7,6 +7,7 @@ import gg.modl.backend.database.mongo.fields.AuditLogFields; import gg.modl.backend.database.mongo.fields.PlayerFields; import gg.modl.backend.database.mongo.fields.TicketFields; +import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.server.data.Server; import gg.modl.backend.ticket.data.TicketStatus; import java.util.ArrayList; @@ -38,6 +39,7 @@ public class AnalyticsMongoRepository { private static final String ALIAS_N = "n"; private static final String ALIAS_COUNT = "count"; private static final String ALIAS_DATE = "date"; + private static final String ALIAS_AVG_MILLIS = "avgMillis"; private static final String EARLIEST_FIRST_LOGIN = "earliestFirstLogin"; private final TenantMongoAccess tenantMongoAccess; @@ -80,7 +82,29 @@ public OverviewStats loadOverviewStats(@NotNull Server server, @NotNull Date thi final long totalPlayers = template.getCollection(CollectionName.PLAYERS).countDocuments(); - return new OverviewStats(totalTickets, totalPlayers, activeTickets, recentTickets, previousTickets); + final List playerPipeline = List.of( + new Document("$addFields", new Document(EARLIEST_FIRST_LOGIN, new Document("$min", "$" + PlayerFields.IP_FIRST_LOGIN))), + matchDateTyped(EARLIEST_FIRST_LOGIN), + new Document("$facet", new Document() + .append(FACET_RECENT, List.of( + new Document("$match", new Document(EARLIEST_FIRST_LOGIN, new Document("$gte", thirtyDaysAgo))), + new Document("$count", ALIAS_N))) + .append(FACET_PREVIOUS, List.of( + new Document("$match", new Document(EARLIEST_FIRST_LOGIN, + new Document("$gte", sixtyDaysAgo).append("$lt", thirtyDaysAgo))), + new Document("$count", ALIAS_N)))) + ); + final List playerResult = template.getCollection(CollectionName.PLAYERS).aggregate(playerPipeline).into(new ArrayList<>()); + + long recentPlayers = 0, previousPlayers = 0; + if (!playerResult.isEmpty()) { + Document facets = playerResult.get(0); + recentPlayers = extractFacetCount(facets, FACET_RECENT); + previousPlayers = extractFacetCount(facets, FACET_PREVIOUS); + } + + return new OverviewStats(totalTickets, totalPlayers, activeTickets, recentTickets, previousTickets, + recentPlayers, previousPlayers); } public List aggregateTicketStatusCounts(Server server, Date startDate) { @@ -142,7 +166,30 @@ public List aggregateDailyTicketCounts(Server server, Date startD .getMappedResults(); } - public Document aggregatePunishmentAnalytics(Server server, Date startDate, String analyticsTimeZone) { + public List aggregateAvgResolutionByCategory(Server server, Date startDate) { + final List pipeline = new ArrayList<>(); + final Document match = new Document(TicketFields.STATUS, TicketStatus.CLOSED.getId()); + if (startDate != null) { + match.append(TicketFields.UPDATED_AT, new Document("$gte", startDate)); + } + pipeline.add(new Document("$match", match)); + pipeline.add(matchDateTyped(TicketFields.CREATED)); + pipeline.add(matchDateTyped(TicketFields.UPDATED_AT)); + pipeline.add(new Document("$group", new Document("_id", "$" + TicketFields.TYPE) + .append(ALIAS_AVG_MILLIS, new Document("$avg", + new Document("$subtract", List.of("$" + TicketFields.UPDATED_AT, "$" + TicketFields.CREATED)))))); + + final List results = tenantMongoAccess.forServer(server) + .getCollection(CollectionName.TICKETS) + .aggregate(pipeline) + .into(new ArrayList<>()); + + return results.stream() + .map(doc -> new CategoryAvgResolution(doc.getString("_id"), doubleValueOrZero(doc.get(ALIAS_AVG_MILLIS)))) + .toList(); + } + + public PunishmentAnalyticsFacet aggregatePunishmentAnalytics(Server server, Date startDate, String analyticsTimeZone) { final List pipeline = new ArrayList<>(); if (startDate != null) { pipeline.add(new Document("$match", new Document(PlayerFields.PUNISHMENT_ISSUED, new Document("$gte", startDate)))); @@ -180,7 +227,15 @@ public Document aggregatePunishmentAnalytics(Server server, Date startDate, Stri .aggregate(pipeline) .into(new ArrayList<>()); - return aggregateResults.isEmpty() ? null : aggregateResults.get(0); + if (aggregateResults.isEmpty()) { + return null; + } + Document facets = aggregateResults.get(0); + return new PunishmentAnalyticsFacet( + toTypeOrdinalCounts(facets.get(FACET_BY_TYPE)), + toIssuerCounts(facets.get(FACET_BY_STAFF)), + toDateCounts(facets.get(FACET_DAILY)) + ); } public List aggregateAuditLogLevelCounts(Server server, Date startDate) { @@ -217,39 +272,64 @@ public List aggregateHourlyAuditLogCounts(Server server, Date since, S .into(new ArrayList<>()); } - public Document aggregatePlayerActivity(Server server, Date startDate, String timeZone) { + public PlayerActivityFacet aggregatePlayerActivity(Server server, Date startDate, String timeZone) { final List pipeline = new ArrayList<>(); pipeline.add(new Document("$match", new Document(PlayerFields.IP_ADDRESSES, new Document("$exists", true).append("$ne", List.of())))); pipeline.add(new Document("$project", new Document(PlayerFields.IP_ADDRESSES, 1))); - 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)); + pipeline.add(new Document("$facet", new Document() + .append(FACET_NEW_PLAYERS, newPlayerFacet(startDate, timeZone)) + .append(FACET_BY_COUNTRY, countryFacet(startDate)) + .append(FACET_SUSPICIOUS, suspiciousFacet(startDate)))); + + final List results = tenantMongoAccess.forServer(server) + .getCollection(CollectionName.PLAYERS) + .aggregate(pipeline) + .into(new ArrayList<>()); + + if (results.isEmpty()) { + return null; + } + Document facets = results.get(0); + return new PlayerActivityFacet( + toDateCounts(facets.get(FACET_NEW_PLAYERS)), + toCountryCounts(facets.get(FACET_BY_COUNTRY)), + toSuspiciousCounts(facets.get(FACET_SUSPICIOUS)) + ); + } + + private static List newPlayerFacet(Date startDate, String timeZone) { + final List facet = new ArrayList<>(); + facet.add(new Document("$addFields", new Document(EARLIEST_FIRST_LOGIN, new Document("$min", "$" + PlayerFields.IP_FIRST_LOGIN)))); + facet.add(matchDateTyped(EARLIEST_FIRST_LOGIN)); if (startDate != null) { - newPlayerFacet.add(new Document("$match", new Document(EARLIEST_FIRST_LOGIN, + facet.add(new Document("$match", new Document(EARLIEST_FIRST_LOGIN, new Document("$gt", startDate)))); } - newPlayerFacet.add(new Document("$group", new Document("_id", + facet.add(new Document("$group", new Document("_id", new Document("$dateToString", new Document("format", "%Y-%m-%d") .append("date", "$" + EARLIEST_FIRST_LOGIN) .append("timezone", timeZone))) .append(ALIAS_COUNT, new Document("$sum", 1)))); - newPlayerFacet.add(new Document("$sort", new Document("_id", 1))); + facet.add(new Document("$sort", new Document("_id", 1))); + return facet; + } - final List countryFacet = new ArrayList<>(); - countryFacet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); + private static List countryFacet(Date startDate) { + final List facet = new ArrayList<>(); + facet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); if (startDate != null) { - countryFacet.add(new Document("$match", new Document("$or", List.of( + facet.add(new Document("$match", new Document("$or", List.of( new Document(PlayerFields.IP_FIRST_LOGIN, new Document("$gt", startDate)), new Document(PlayerFields.IP_ADDRESSES + ".logins", new Document("$elemMatch", new Document("$gt", startDate))) )))); } - countryFacet.add(new Document("$match", new Document(PlayerFields.IP_ADDRESSES + ".country", + facet.add(new Document("$match", new Document(PlayerFields.IP_ADDRESSES + ".country", new Document("$exists", true).append("$ne", "")))); Document loginCountExpr; @@ -266,35 +346,27 @@ public Document aggregatePlayerActivity(Server server, Date startDate, String ti new Document("$size", new Document("$ifNull", List.of("$" + PlayerFields.IP_ADDRESSES + ".logins", List.of()))))); } - countryFacet.add(new Document("$group", new Document("_id", "$" + PlayerFields.IP_ADDRESSES + ".country") + facet.add(new Document("$group", new Document("_id", "$" + PlayerFields.IP_ADDRESSES + ".country") .append(ALIAS_COUNT, new Document("$sum", loginCountExpr)))); - countryFacet.add(new Document("$sort", new Document(ALIAS_COUNT, -1))); - countryFacet.add(new Document("$limit", 20)); + facet.add(new Document("$sort", new Document(ALIAS_COUNT, -1))); + facet.add(new Document("$limit", 20)); + return facet; + } - final List suspiciousFacet = new ArrayList<>(); - suspiciousFacet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); + private static List suspiciousFacet(Date startDate) { + final List facet = new ArrayList<>(); + facet.add(new Document("$unwind", "$" + PlayerFields.IP_ADDRESSES)); if (startDate != null) { - suspiciousFacet.add(new Document("$match", new Document("$or", List.of( + facet.add(new Document("$match", new Document("$or", List.of( new Document(PlayerFields.IP_FIRST_LOGIN, new Document("$gt", startDate)), new Document(PlayerFields.IP_ADDRESSES + ".logins", new Document("$elemMatch", new Document("$gt", startDate))) )))); } - suspiciousFacet.add(new Document("$group", new Document("_id", null) + facet.add(new Document("$group", new Document("_id", null) .append("proxyCount", new Document("$sum", new Document("$cond", List.of("$" + PlayerFields.IP_ADDRESSES + ".proxy", 1, 0)))) .append("hostingCount", new Document("$sum", new Document("$cond", List.of("$" + PlayerFields.IP_ADDRESSES + ".hosting", 1, 0)))))); - - pipeline.add(new Document("$facet", new Document() - .append(FACET_NEW_PLAYERS, newPlayerFacet) - .append(FACET_BY_COUNTRY, countryFacet) - .append(FACET_SUSPICIOUS, suspiciousFacet))); - - final List results = tenantMongoAccess.forServer(server) - .getCollection(CollectionName.PLAYERS) - .aggregate(pipeline) - .into(new ArrayList<>()); - - return results.isEmpty() ? null : results.get(0); + return facet; } public record OverviewStats( @@ -302,9 +374,95 @@ public record OverviewStats( long totalPlayers, long activeTickets, long recentTickets, - long previousTickets + long previousTickets, + long recentPlayers, + long previousPlayers ) { } public record IdCountResult(String id, int count) {} + + public record CategoryAvgResolution(String id, double avgMillis) {} + + public record PunishmentAnalyticsFacet( + List byType, + List byStaff, + List daily + ) {} + + public record PlayerActivityFacet( + List newPlayers, + List byCountry, + SuspiciousCounts suspicious + ) {} + + public record TypeOrdinalCount(Integer typeOrdinal, int count) {} + + public record IssuerCount(String issuerId, int count) {} + + public record DateCountResult(String date, int count) {} + + public record CountryCount(String country, int count) {} + + public record SuspiciousCounts(int proxyCount, int hostingCount) {} + + private static double doubleValueOrZero(Object value) { + return value instanceof Number number ? number.doubleValue() : 0.0; + } + + private static List facetDocuments(Object value) { + if (!(value instanceof List rawList)) { + return List.of(); + } + List documents = new ArrayList<>(); + for (Object item : rawList) { + if (item instanceof Document document) { + documents.add(document); + } + } + return documents; + } + + private static List toTypeOrdinalCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new TypeOrdinalCount( + doc.get("_id") instanceof Number number ? number.intValue() : null, + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static List toIssuerCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new IssuerCount( + doc.get("_id") instanceof String issuerId ? issuerId : null, + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static List toDateCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new DateCountResult( + doc.getString("_id"), + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static List toCountryCounts(Object value) { + return facetDocuments(value).stream() + .map(doc -> new CountryCount( + doc.getString("_id"), + ProtoMapperSupport.intValueOrZero(doc.get(ALIAS_COUNT)))) + .toList(); + } + + private static SuspiciousCounts toSuspiciousCounts(Object value) { + List documents = facetDocuments(value); + if (documents.isEmpty()) { + return new SuspiciousCounts(0, 0); + } + Document first = documents.get(0); + return new SuspiciousCounts( + ProtoMapperSupport.intValueOrZero(first.get("proxyCount")), + ProtoMapperSupport.intValueOrZero(first.get("hostingCount"))); + } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java new file mode 100644 index 0000000..b90b08f --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/AuditLogRepository.java @@ -0,0 +1,66 @@ +package gg.modl.backend.database.mongo.repository; + +import gg.modl.backend.audit.data.AuditLog; +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.AuditLogFields; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.regex.Pattern; +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.stereotype.Repository; + +@Repository +public class AuditLogRepository extends AbstractServerMongoRepository { + private static final String LEVEL_MODERATION = "moderation"; + private static final String LEVEL_INFO = "info"; + + public AuditLogRepository(TenantMongoAccess tenantMongoAccess) { + super(AuditLog.class, CollectionName.LOGS, tenantMongoAccess); + } + + public void saveAuditLog(Server server, AuditLog auditLog) { + saveEntity(server, auditLog); + } + + public List findPunishmentLogs(Server server, Date startDate, int limit, boolean canRollbackOnly) { + Criteria criteria = Criteria.where(AuditLogFields.CREATED).gte(startDate) + .orOperator( + Criteria.where(AuditLogFields.LEVEL).is(LEVEL_MODERATION), + Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("ban|mute|kick|warn", Pattern.CASE_INSENSITIVE)) + ); + + if (canRollbackOnly) { + criteria = criteria.and(AuditLogFields.METADATA_CAN_ROLLBACK).ne(false); + } + + Query query = Query.query(criteria) + .with(Sort.by(Sort.Direction.DESC, AuditLogFields.CREATED)) + .limit(limit); + + return find(server, query); + } + + public long countEvidenceUploads(Server server, String username, Date startDate) { + Criteria baseCriteria = Criteria.where(AuditLogFields.SOURCE).is(username); + if (startDate != null) { + baseCriteria = baseCriteria.and(AuditLogFields.CREATED).gte(startDate); + } + + Query query = Query.query( + baseCriteria.orOperator( + Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("evidence|upload|file", Pattern.CASE_INSENSITIVE)), + Criteria.where(AuditLogFields.LEVEL) + .is(LEVEL_INFO) + .and(AuditLogFields.DESCRIPTION) + .regex(Pattern.compile("uploaded|attachment", Pattern.CASE_INSENSITIVE)) + ) + ); + + return count(server, query); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java index b7565c7..2723ec6 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/GlobalMongoAdminRepository.java @@ -1,5 +1,6 @@ package gg.modl.backend.database.mongo.repository; +import gg.modl.backend.database.mongo.MongoAggregationResults; import gg.modl.backend.database.mongo.TenantMongoAccess; import lombok.RequiredArgsConstructor; import org.bson.Document; @@ -16,7 +17,6 @@ public void ping() { public long getStorageSize() { Document dbStats = tenantMongoAccess.global().getDb().runCommand(new Document("dbStats", 1)); - Object value = dbStats.get("storageSize"); - return value instanceof Number number ? number.longValue() : 0L; + return MongoAggregationResults.extractLong(dbStats, "storageSize"); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java index fcfba6f..d1a1aa0 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/HomepageCardMongoRepository.java @@ -10,9 +10,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; -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; @@ -112,15 +110,6 @@ public boolean deleteByCardId(Server server, String id) { } public void reorderCards(Server server, List ids) { - if (ids.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (int index = 0; index < ids.size(); index++) { - Query query = Query.query(Criteria.where(HomepageCardFields.ID).is(ids.get(index))); - Update update = new Update().set(HomepageCardFields.ORDINAL, index); - bulk.updateOne(query, update); - } - bulk.execute(); + bulkUpdateByIds(server, ids, (id, index) -> new Update().set(HomepageCardFields.ORDINAL, index)); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java index b5388e6..d0a1e1a 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseArticleMongoRepository.java @@ -12,9 +12,7 @@ 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.FindAndModifyOptions; -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; @@ -132,18 +130,11 @@ public List searchVisibleArticles(Server server, String se } public void reorderArticles(Server server, String categoryId, List ids) { - if (ids.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (int index = 0; index < ids.size(); index++) { - Query query = Query.query(new Criteria().andOperator( - Criteria.where(KnowledgebaseArticleFields.ID).is(ids.get(index)), + bulkUpdateByIds(server, ids, + (id, index) -> new Criteria().andOperator( + Criteria.where(KnowledgebaseArticleFields.ID).is(id), Criteria.where(KnowledgebaseArticleFields.CATEGORY_ID).is(categoryId) - )); - Update update = new Update().set(KnowledgebaseArticleFields.ORDINAL, index); - bulk.updateOne(query, update); - } - bulk.execute(); + ), + (id, index) -> new Update().set(KnowledgebaseArticleFields.ORDINAL, index)); } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java index 0283f7c..113ab85 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/KnowledgebaseCategoryMongoRepository.java @@ -2,7 +2,6 @@ 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.KnowledgebaseCategoryFields; import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; @@ -12,9 +11,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.domain.Sort; -import org.springframework.data.mongodb.core.BulkOperations; import org.springframework.data.mongodb.core.FindAndModifyOptions; -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; @@ -97,15 +94,6 @@ public boolean deleteByCategoryId(Server server, String id) { } public void reorderCategories(Server server, List ids) { - if (ids.isEmpty()) return; - - MongoTemplate template = serverTemplate(server); - BulkOperations bulk = template.bulkOps(BulkOperations.BulkMode.UNORDERED, collectionName()); - for (int index = 0; index < ids.size(); index++) { - Query query = Query.query(Criteria.where(KnowledgebaseCategoryFields.ID).is(ids.get(index))); - Update update = new Update().set(KnowledgebaseCategoryFields.ORDINAL, index); - bulk.updateOne(query, update); - } - bulk.execute(); + bulkUpdateByIds(server, ids, (id, index) -> new Update().set(KnowledgebaseCategoryFields.ORDINAL, index)); } } 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 456d3af..bcf8252 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,5 +1,6 @@ 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.MigrationStatusFields; @@ -16,12 +17,11 @@ @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); + super(MigrationStatus.class, CollectionName.MIGRATIONS, tenantMongoAccess); } public Optional findLatest(Server server) { 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 409c251..533c075 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 @@ -6,6 +6,7 @@ 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.player.data.PlayerDataView; import gg.modl.backend.server.data.Server; import java.util.Collection; import java.util.Date; @@ -23,6 +24,8 @@ @Repository public class PlayerMongoRepository extends AbstractServerMongoRepository { + private static final int DEFAULT_QUERY_LIMIT = 1000; + public PlayerMongoRepository(TenantMongoAccess tenantMongoAccess) { super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); } @@ -54,8 +57,6 @@ public List findOnlinePlayers(Server server, int 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); } @@ -70,6 +71,15 @@ public List findByMinecraftUuids(Server server, Collection minec return find(server, query); } + 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 List findByIpAddresses(Server server, Collection ipAddresses) { return findByIpAddresses(server, ipAddresses, DEFAULT_QUERY_LIMIT); } @@ -110,7 +120,7 @@ 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()); + .set(PlayerFields.DATA, player.data().asMap()); updateById(server, player.getId(), update); } @@ -153,17 +163,16 @@ public void addLinkedAccounts(Server server, String minecraftUuid, Collection data = player.getData(); - Object linkedAccounts = data != null ? data.get("linkedAccounts") : null; - Object lastLinkedUpdate = data != null ? data.get("lastLinkedUpdate") : null; + PlayerDataView data = player.data(); + Date lastLinkedUpdate = data.lastLinkedUpdate(); - Update update = new Update().set(PlayerFields.DATA_LINKED_ACCOUNTS, linkedAccounts); - if (lastLinkedUpdate instanceof Date date) { - update.set(PlayerFields.DATA_LAST_LINKED_UPDATE, date); + Update update = new Update().set(PlayerFields.DATA_LINKED_ACCOUNTS, data.linkedAccountsValue()); + if (lastLinkedUpdate != null) { + update.set(PlayerFields.DATA_LAST_LINKED_UPDATE, lastLinkedUpdate); } else { update.unset(PlayerFields.DATA_LAST_LINKED_UPDATE); } @@ -202,16 +211,7 @@ public void markStalePlayersOffline(Server server, Collection onlineUuid 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) { + private BulkOperations bulkOps(Server server) { return serverTemplate(server).bulkOps( BulkOperations.BulkMode.UNORDERED, Player.class, @@ -259,5 +259,4 @@ public long countOnlineByUuids(Server server, Collection uuids) { )); return count(server, query); } - } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java index 42ff9e7..9f0cbe2 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/PunishmentMongoRepository.java @@ -13,17 +13,21 @@ import gg.modl.backend.player.data.punishment.PunishmentNote; import gg.modl.backend.player.data.punishment.PunishmentStatus; import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.data.punishment.PunishmentData; import gg.modl.backend.server.data.Server; +import java.util.ArrayList; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; import org.bson.Document; import org.jetbrains.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.aggregation.AggregationOptions; import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -32,6 +36,26 @@ @Repository public class PunishmentMongoRepository extends AbstractServerMongoRepository { + private static final String DATA_STATUS_SUFFIX = ".status"; + private static final String DATA_DURATION_SUFFIX = ".duration"; + private static final String DATA_APPEAL_OUTCOME_SUFFIX = ".appealOutcome"; + private static final String DATA_APPEAL_TICKET_ID_SUFFIX = ".appealTicketId"; + private static final String DATA_STAT_WIPE_COMPLETED_SUFFIX = "." + PunishmentData.STAT_WIPE_COMPLETED; + private static final String DATA_STAT_WIPE_COMPLETED_AT_SUFFIX = "." + PunishmentData.STAT_WIPE_COMPLETED + "At"; + private static final String PUNISHMENTS_PREFIX = PlayerFields.PUNISHMENTS + "."; + private static final String ALIAS_PUNISHMENT_ID = "punishmentId"; + private static final String ALIAS_PLAYER_ID = "playerId"; + private static final String ALIAS_TYPE_ORDINAL = "typeOrdinal"; + private static final String ALIAS_ISSUER_NAME = "issuerName"; + private static final String ALIAS_ISSUER_ID = "issuerId"; + private static final String ALIAS_ISSUED = "issued"; + private static final String ALIAS_STARTED = "started"; + private static final String ALIAS_DATA = "data"; + private static final String ALIAS_MODIFICATIONS = "modifications"; + private static final String ALIAS_EVIDENCE = "evidence"; + private static final String ALIAS_ATTACHED_TICKET_IDS = "attachedTicketIds"; + private static final String ALIAS_USERNAMES = "usernames"; + public PunishmentMongoRepository(TenantMongoAccess tenantMongoAccess) { super(Player.class, CollectionName.PLAYERS, tenantMongoAccess); } @@ -62,7 +86,7 @@ public void appendPardon(Server server, String playerUuid, String punishmentId, Query query = punishmentQuery(playerUuid, punishmentId); Update update = new Update() .push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification) - .set(PlayerFields.PUNISHMENT_DATA + ".status", status); + .set(PlayerFields.PUNISHMENT_DATA + DATA_STATUS_SUFFIX, status); if (!notes.isEmpty()) { update.push(PlayerFields.PUNISHMENT_NOTES).each(notes.toArray()); } @@ -75,7 +99,7 @@ public void appendDurationChange(Server server, String playerUuid, String punish Update update = new Update() .push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification) .push(PlayerFields.PUNISHMENT_NOTES, note) - .set(PlayerFields.PUNISHMENT_DATA + ".duration", effectiveDuration); + .set(PlayerFields.PUNISHMENT_DATA + DATA_DURATION_SUFFIX, effectiveDuration); updateFirst(server, query, update); } @@ -115,6 +139,24 @@ public void setPunishmentData(Server server, String playerUuid, String punishmen updateFirst(server, query, update); } + public void markStatWipeAcknowledged(Server server, String playerUuid, String punishmentId, Date completedAt) { + Query query = punishmentQuery(playerUuid, punishmentId); + Update update = new Update() + .set(PlayerFields.PUNISHMENT_DATA + DATA_STAT_WIPE_COMPLETED_SUFFIX, true) + .set(PlayerFields.PUNISHMENT_DATA + DATA_STAT_WIPE_COMPLETED_AT_SUFFIX, completedAt); + updateFirst(server, query, update); + } + + public void appendAppealResultNote(Server server, String playerUuid, String punishmentId, + PunishmentNote note, String appealOutcome, String appealTicketId) { + Query query = punishmentQuery(playerUuid, punishmentId); + Update update = new Update() + .push(PlayerFields.PUNISHMENT_NOTES, note) + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_OUTCOME_SUFFIX, appealOutcome) + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_TICKET_ID_SUFFIX, appealTicketId); + updateFirst(server, query, update); + } + public void setPunishmentTickets(Server server, String playerUuid, String punishmentId, List ticketIds) { Query query = punishmentQuery(playerUuid, punishmentId); Update update = new Update().set(PlayerFields.PUNISHMENT_ATTACHED_TICKET_IDS, ticketIds); @@ -124,7 +166,7 @@ public void setPunishmentTickets(Server server, String playerUuid, String punish public boolean acknowledgePunishmentStart(Server server, String playerUuid, String punishmentId, Date started) { Update update = new Update() .set(PlayerFields.PUNISHMENT_STARTED, started) - .unset(PlayerFields.PUNISHMENT_DATA + ".status"); + .unset(PlayerFields.PUNISHMENT_DATA + DATA_STATUS_SUFFIX); return updateFirst(server, startedUnsetQuery(playerUuid, punishmentId), update).getModifiedCount() > 0; } @@ -167,8 +209,8 @@ public void applyAppealApproval(Server server, String playerUuid, String punishm Update update = new Update() .push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification) .push(PlayerFields.PUNISHMENT_NOTES, note) - .set(PlayerFields.PUNISHMENT_DATA + ".appealOutcome", appealOutcome) - .set(PlayerFields.PUNISHMENT_DATA + ".appealTicketId", appealTicketId); + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_OUTCOME_SUFFIX, appealOutcome) + .set(PlayerFields.PUNISHMENT_DATA + DATA_APPEAL_TICKET_ID_SUFFIX, appealTicketId); updateFirst(server, query, update); } @@ -307,7 +349,70 @@ public void unsetPunishmentStatus(Server server, String playerUuid, String punis Criteria.where(PlayerFields.MINECRAFT_UUID).is(playerUuid) .and(PlayerFields.PUNISHMENT_ID).is(punishmentId) ); - Update update = new Update().unset(PlayerFields.PUNISHMENT_DATA + ".status"); + Update update = new Update().unset(PlayerFields.PUNISHMENT_DATA + DATA_STATUS_SUFFIX); updateFirst(server, query, update); } + + public List aggregatePunishmentRows(Server server) { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(PlayerFields.PUNISHMENTS).exists(true).ne(List.of())), + Aggregation.unwind(PlayerFields.PUNISHMENTS), + Aggregation.match(Criteria.where(PlayerFields.PUNISHMENT_TYPE_ORDINAL).ne(0) + .and(PlayerFields.PUNISHMENT_DATA_STATUS).ne(PunishmentStatus.UNSTARTED)), + Aggregation.sort(Sort.Direction.DESC, PlayerFields.PUNISHMENT_ISSUED), + Aggregation.project() + .and(PlayerFields.PUNISHMENT_ID).as(ALIAS_PUNISHMENT_ID) + .and(PlayerFields.MINECRAFT_UUID).as(ALIAS_PLAYER_ID) + .and(PlayerFields.PUNISHMENT_TYPE_ORDINAL).as(ALIAS_TYPE_ORDINAL) + .and(PlayerFields.PUNISHMENT_ISSUER_NAME).as(ALIAS_ISSUER_NAME) + .and(PlayerFields.PUNISHMENT_ISSUER_ID).as(ALIAS_ISSUER_ID) + .and(PlayerFields.PUNISHMENT_ISSUED).as(ALIAS_ISSUED) + .and(PUNISHMENTS_PREFIX + PunishmentFields.STARTED).as(ALIAS_STARTED) + .and(PUNISHMENTS_PREFIX + PunishmentFields.DATA).as(ALIAS_DATA) + .and(PUNISHMENTS_PREFIX + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) + .and(PUNISHMENTS_PREFIX + PunishmentFields.EVIDENCE).as(ALIAS_EVIDENCE) + .and(PUNISHMENTS_PREFIX + PunishmentFields.ATTACHED_TICKET_IDS).as(ALIAS_ATTACHED_TICKET_IDS) + .and(PlayerFields.USERNAMES).as(ALIAS_USERNAMES) + ).withOptions(AggregationOptions.builder().allowDiskUse(true).build()); + + return aggregate(server, aggregation, Document.class).getMappedResults(); + } + + public Document findPlayerByPunishmentId(Server server, String punishmentId) { + Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENT_ID).is(punishmentId)); + query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, + PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); + return serverTemplate(server).findOne(query, Document.class, collectionName()); + } + + public List findPlayersForRollback(Server server, String staffUsername, String staffId) { + List issuerMatch = new ArrayList<>(); + issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_NAME).regex("^" + Pattern.quote(staffUsername) + "$", "i")); + if (staffId != null) { + issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_ID).is(staffId)); + } + + Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( + new Criteria().orOperator(issuerMatch.toArray(new Criteria[0])) + )); + query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, PlayerFields.PUNISHMENTS); + + return serverTemplate(server).find(query, Document.class, collectionName()); + } + + public List findPlayersForBulkAction(Server server, List typeOrdinals) { + Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( + Criteria.where(PunishmentFields.TYPE_ORDINAL).in(typeOrdinals) + )); + query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, + PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); + return serverTemplate(server).find(query, Document.class, collectionName()); + } + + public void appendPunishmentModification(Server server, String playerId, String punishmentId, + Map modification) { + Update update = new Update().push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification); + Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId).and(PlayerFields.PUNISHMENT_ID).is(punishmentId)); + serverTemplate(server).updateFirst(query, update, collectionName()); + } } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java index 9f28be1..af03e85 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SecurityEventMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.SecurityEvent; +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.SecurityEventFields; @@ -12,13 +13,12 @@ import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; @Repository public class SecurityEventMongoRepository extends AbstractGlobalMongoRepository { - public static final String COLLECTION_NAME = "security_events"; - public SecurityEventMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SecurityEvent.class, COLLECTION_NAME, tenantMongoAccess); + super(SecurityEvent.class, CollectionName.SECURITY_EVENTS, tenantMongoAccess); } public List findSecurityEvents( @@ -48,16 +48,16 @@ private Query buildEventsQuery( Query query = new Query(); List criteriaList = new ArrayList<>(); - if (hasText(type)) { + if (StringUtils.hasText(type)) { criteriaList.add(Criteria.where(SecurityEventFields.TYPE).is(type)); } - if (hasText(severity)) { + if (StringUtils.hasText(severity)) { criteriaList.add(Criteria.where(SecurityEventFields.SEVERITY).is(severity)); } - if (hasText(source)) { + if (StringUtils.hasText(source)) { criteriaList.add(Criteria.where(SecurityEventFields.SOURCE).is(source)); } - if (hasText(search)) { + if (StringUtils.hasText(search)) { criteriaList.add(Criteria.where(SecurityEventFields.DESCRIPTION).regex(Pattern.quote(search), "i")); } if (startDate != null || endDate != null) { @@ -76,10 +76,6 @@ private Query buildEventsQuery( return query; } - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } - public long countSecurityEvents( String type, String severity, diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java new file mode 100644 index 0000000..482a16d --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerActivityRepository.java @@ -0,0 +1,36 @@ +package gg.modl.backend.database.mongo.repository; + +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.Server; +import java.util.Date; +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 ServerActivityRepository extends AbstractGlobalMongoRepository { + + public ServerActivityRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public void updateActivity(Server server, Date lastActivityAt) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(server.getId())), + new Update().set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) + ); + } + + public void updateActivityAndPlayerCount(Server server, Date lastActivityAt, long onlinePlayerCount) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(server.getId())), + new Update() + .set(ServerFields.LAST_ACTIVITY_AT, lastActivityAt) + .set(ServerFields.ONLINE_PLAYER_COUNT, onlinePlayerCount) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java new file mode 100644 index 0000000..0ea7780 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerAdminRepository.java @@ -0,0 +1,164 @@ +package gg.modl.backend.database.mongo.repository; + +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.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.server.data.ServerPlan; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +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 ServerAdminRepository 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 ORDER_ASC = "asc"; + private static final String INVALID_PLAN_SENTINEL = "__invalid_plan__"; + + 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 ServerAdminRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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 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 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(); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java new file mode 100644 index 0000000..798382d --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerBetaTesterRepository.java @@ -0,0 +1,71 @@ +package gg.modl.backend.database.mongo.repository; + +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.Server; +import gg.modl.backend.server.data.ServerPlan; +import gg.modl.backend.server.data.SubscriptionStatus; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +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 ServerBetaTesterRepository extends AbstractGlobalMongoRepository { + + public ServerBetaTesterRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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) + )); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java new file mode 100644 index 0000000..68e1a16 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCredentialRepository.java @@ -0,0 +1,43 @@ +package gg.modl.backend.database.mongo.repository; + +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.Server; +import java.util.Date; +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 ServerCredentialRepository extends AbstractGlobalMongoRepository { + + public ServerCredentialRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java new file mode 100644 index 0000000..9aa0f42 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerCustomDomainRepository.java @@ -0,0 +1,48 @@ +package gg.modl.backend.database.mongo.repository; + +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.Server; +import java.util.Date; +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 ServerCustomDomainRepository extends AbstractGlobalMongoRepository { + + public ServerCustomDomainRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public void updateCustomDomain(String serverId, String customDomain, CustomDomainStatus status, + String cloudflareHostnameId, String error) { + updateFirst( + Query.query(Criteria.where(ServerFields.ID).is(serverId)), + new Update() + .set(ServerFields.CUSTOM_DOMAIN_OVERRIDE, customDomain) + .set(ServerFields.CUSTOM_DOMAIN_STATUS, status.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()) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java index 2dd279a..839a5ba 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerInstanceSnapshotMongoRepository.java @@ -38,7 +38,7 @@ public void upsertServerEntry(Date date, String serverId, String serverName, int private boolean tryPersistServerEntry(Date date, ServerInstanceSnapshot.ServerEntry entry, Date createdAt) { Query updateQuery = Query.query( Criteria.where(ServerInstanceSnapshotFields.DATE).is(date) - .and("servers").elemMatch( + .and(ServerInstanceSnapshotFields.SERVERS).elemMatch( Criteria.where("serverId").is(entry.getServerId()).and("serverName").is(entry.getServerName())) ); Update updateExisting = new Update() @@ -54,7 +54,7 @@ private boolean tryPersistServerEntry(Date date, ServerInstanceSnapshot.ServerEn Query insertQuery = Query.query( Criteria.where(ServerInstanceSnapshotFields.DATE).is(date) - .and("servers").not().elemMatch( + .and(ServerInstanceSnapshotFields.SERVERS).not().elemMatch( Criteria.where("serverId").is(entry.getServerId()).and("serverName").is(entry.getServerName())) ); Update pushNew = new Update() diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java index 7b95abb..0ec248a 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLogMongoRepository.java @@ -4,7 +4,7 @@ import gg.modl.backend.database.mongo.AbstractServerMongoRepository; import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.ServerLogFields; -import gg.modl.backend.log.data.SystemLog; +import gg.modl.backend.log.data.ServerLog; import gg.modl.backend.server.data.Server; import java.util.List; import org.springframework.data.domain.Sort; @@ -12,12 +12,12 @@ import org.springframework.stereotype.Repository; @Repository -public class ServerLogMongoRepository extends AbstractServerMongoRepository { +public class ServerLogMongoRepository extends AbstractServerMongoRepository { public ServerLogMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SystemLog.class, CollectionName.LOGS, tenantMongoAccess); + super(ServerLog.class, CollectionName.LOGS, tenantMongoAccess); } - public List findRecent(Server server, int limit) { + public List findRecent(Server server, int limit) { Query query = new Query() .with(Sort.by(Sort.Direction.DESC, ServerLogFields.CREATED)) .limit(limit); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java new file mode 100644 index 0000000..279cd7e --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerLookupRepository.java @@ -0,0 +1,90 @@ +package gg.modl.backend.database.mongo.repository; + +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.Server; +import gg.modl.backend.server.data.SubscriptionStatus; +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerLookupRepository extends AbstractGlobalMongoRepository { + private static final String DOMAIN_STATUS_ACTIVE = "ACTIVE"; + + public ServerLookupRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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 findByProvisioningSignInToken(String token) { + return findOne(Query.query(Criteria.where(ServerFields.PROVISIONING_SIGN_IN_TOKEN).is(token))); + } + + 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 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)); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java new file mode 100644 index 0000000..719c15b --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMetricsRepository.java @@ -0,0 +1,259 @@ +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 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.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.DateOperators; +import org.springframework.data.mongodb.core.aggregation.ProjectionOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +@Repository +public class ServerMetricsRepository extends AbstractGlobalMongoRepository { + 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"; + + public ServerMetricsRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + public long countAll() { + return count(new Query()); + } + + public long countByProvisioningStatus(ProvisioningStatus status) { + return count(Query.query(Criteria.where(ServerFields.PROVISIONING_STATUS).is(status))); + } + + public long countActiveSince(Date activityCutoff) { + return count(Query.query(Criteria.where(ServerFields.LAST_ACTIVITY_AT).gte(activityCutoff))); + } + + 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 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(ALIAS_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(ALIAS_TOTAL_USERS, new Document("$sum", "$" + ServerFields.USER_COUNT)) + .append(ALIAS_TOTAL_TICKETS, 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, ALIAS_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, ALIAS_TOTAL_USERS); + totalTickets = extractLong(usageDoc, ALIAS_TOTAL_TICKETS); + } + + return new DashboardStats(total, active, withUsers, currentPeriod, previousPeriod, totalUsers, totalTickets); + } + + public MonitoringServerStats aggregateMonitoringServerStats(Date fiveMinutesAgo, Date oneWeekAgo) { + Document facet = new Document() + .append(ALIAS_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, ALIAS_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 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/ServerMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java deleted file mode 100644 index 969276d..0000000 --- a/src/main/java/gg/modl/backend/database/mongo/repository/ServerMongoRepository.java +++ /dev/null @@ -1,1127 +0,0 @@ -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/ServerProvisioningRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerProvisioningRepository.java new file mode 100644 index 0000000..6f5296a --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerProvisioningRepository.java @@ -0,0 +1,107 @@ +package gg.modl.backend.database.mongo.repository; + +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.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +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 ServerProvisioningRepository extends AbstractGlobalMongoRepository { + private static final String RESET_MESSAGE = "Database reset - awaiting reprovisioning"; + + public ServerProvisioningRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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 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 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 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) { + Update update = new Update() + .set(ServerFields.PROVISIONING_STATUS, ProvisioningStatus.FAILED) + .set(ServerFields.PROVISIONING_NOTES, notes) + .set(ServerFields.UPDATED_AT, new Date()); + return updateFirst(Query.query(Criteria.where(ServerFields.ID).is(serverId)), update) + .getModifiedCount() > 0; + } + + 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); + } + + private Criteria noCleanupClaimCriteria() { + return new Criteria().orOperator( + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).exists(false), + Criteria.where(ServerFields.CLEANUP_CLAIM_ID).is(null) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java new file mode 100644 index 0000000..b38fd0c --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerRegistrationCleanupRepository.java @@ -0,0 +1,167 @@ +package gg.modl.backend.database.mongo.repository; + +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.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.time.Instant; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +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 ServerRegistrationCleanupRepository extends AbstractGlobalMongoRepository { + + public ServerRegistrationCleanupRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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; + } + + 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 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) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java new file mode 100644 index 0000000..95145e2 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerSettingsTimestampRepository.java @@ -0,0 +1,38 @@ +package gg.modl.backend.database.mongo.repository; + +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.Server; +import java.util.Date; +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 ServerSettingsTimestampRepository extends AbstractGlobalMongoRepository { + + public ServerSettingsTimestampRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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()) + ); + } +} diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java new file mode 100644 index 0000000..bfa8476 --- /dev/null +++ b/src/main/java/gg/modl/backend/database/mongo/repository/ServerUsageRepository.java @@ -0,0 +1,191 @@ +package gg.modl.backend.database.mongo.repository; + +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.ProvisioningStatus; +import gg.modl.backend.server.data.Server; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.bson.Document; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.aggregation.AggregationExpression; +import org.springframework.data.mongodb.core.aggregation.AggregationUpdate; +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 ServerUsageRepository extends AbstractGlobalMongoRepository { + + public ServerUsageRepository(TenantMongoAccess tenantMongoAccess) { + super(Server.class, CollectionName.MODL_SERVERS, tenantMongoAccess); + } + + 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 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); + } + + 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 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 record AIUsageSnapshot(long aiRequestsCurrentPeriod, long maxAiOverageRequests) {} +} 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 13a6c2c..012f685 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 @@ -31,11 +31,7 @@ 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) { + public void upsertListData(Server server, String type, Object data) { Query query = Query.query(Criteria.where(SettingsFields.TYPE).is(type)); Update update = new Update() .set(SettingsFields.TYPE, type) @@ -43,16 +39,6 @@ private void upsertRawData(Server server, String type, Object 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))); } diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/AuditMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StaffActivityAnalyticsRepository.java similarity index 57% rename from src/main/java/gg/modl/backend/database/mongo/repository/AuditMongoRepository.java rename to src/main/java/gg/modl/backend/database/mongo/repository/StaffActivityAnalyticsRepository.java index 641d990..0ed8ba0 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/AuditMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StaffActivityAnalyticsRepository.java @@ -1,46 +1,35 @@ package gg.modl.backend.database.mongo.repository; -import gg.modl.backend.audit.data.AuditLog; import gg.modl.backend.database.CollectionName; - import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.fields.AuditLogFields; import gg.modl.backend.database.mongo.fields.PlayerFields; import gg.modl.backend.database.mongo.fields.PunishmentFields; -import gg.modl.backend.database.mongo.fields.StaffFields; import gg.modl.backend.database.mongo.fields.TicketFields; -import gg.modl.backend.player.data.punishment.PunishmentStatus; import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.data.Staff; import java.util.ArrayList; import java.util.Date; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.bson.Document; import org.springframework.data.domain.Sort; import org.springframework.data.mongodb.core.aggregation.Aggregation; import org.springframework.data.mongodb.core.aggregation.AggregationOperation; -import org.springframework.data.mongodb.core.aggregation.AggregationOptions; import org.springframework.data.mongodb.core.aggregation.ConditionalOperators; +import org.springframework.data.mongodb.core.aggregation.StringOperators; 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 @RequiredArgsConstructor -public class AuditMongoRepository { +public class StaffActivityAnalyticsRepository { private final TenantMongoAccess tenantMongoAccess; + private static final String PUNISHMENTS_PREFIX = PlayerFields.PUNISHMENTS + "."; private static final String TOTAL_ACTIONS = "totalActions"; private static final String COUNT = "count"; private static final String LEVEL_MODERATION = "moderation"; - private static final String LEVEL_INFO = "info"; private static final String SOURCE_SYSTEM = "system"; - private static final String STATUS_UNSTARTED = PunishmentStatus.UNSTARTED; private static final String DATE = "date"; private static final String ALIAS_TICKET_ACTIONS = "ticketActions"; private static final String ALIAS_MODERATION_ACTIONS = "moderationActions"; @@ -49,14 +38,9 @@ public class AuditMongoRepository { private static final String ALIAS_PUNISHMENT_ID = "punishmentId"; private static final String ALIAS_PLAYER_ID = "playerId"; private static final String ALIAS_TYPE_ORDINAL = "typeOrdinal"; - private static final String ALIAS_ISSUER_NAME = "issuerName"; - private static final String ALIAS_ISSUER_ID = "issuerId"; private static final String ALIAS_ISSUED = "issued"; private static final String ALIAS_STARTED = "started"; - private static final String ALIAS_DATA = "data"; private static final String ALIAS_MODIFICATIONS = "modifications"; - private static final String ALIAS_EVIDENCE = "evidence"; - private static final String ALIAS_ATTACHED_TICKET_IDS = "attachedTicketIds"; private static final String ALIAS_USERNAMES = "usernames"; private static final String ALIAS_REASON = "reason"; private static final String ALIAS_DURATION = "duration"; @@ -66,12 +50,10 @@ public class AuditMongoRepository { private static final String ALIAS_TICKET_CREATED = "ticketCreated"; private static final String ALIAS_LAST_ACTIVITY = "lastActivity"; private static final String ALIAS_FIRST_REPLY = "firstReply"; - - public List findAllStaff(Server server) { - Query query = new Query(); - query.fields().include(StaffFields.ID, StaffFields.USERNAME, StaffFields.EMAIL, StaffFields.ROLE_ID, StaffFields.ASSIGNED_MINECRAFT_USERNAME, StaffFields.UPDATED_AT); - return tenantMongoAccess.forServer(server).find(query, Staff.class, CollectionName.STAFF); - } + private static final String ALIAS_STAFF = "staff"; + private static final String ALIAS_STAFF_KEY = "staffKey"; + private static final String ALIAS_TICKET_ID = "ticketId"; + private static final String ALIAS_REPLY_CREATED = "replyCreated"; public List aggregateLogActivityBySource(Server server, Date startDate) { Criteria logCriteria = Criteria.where(AuditLogFields.SOURCE).ne(SOURCE_SYSTEM); @@ -131,130 +113,6 @@ public List aggregatePunishmentCountsByIssuer(Server server, Date .getMappedResults(); } - public Map mapStaffUsernamesByIds(Server server, Set staffIds) { - if (staffIds == null || staffIds.isEmpty()) { - return Map.of(); - } - - Query query = Query.query(Criteria.where(StaffFields.ID).in(staffIds)); - query.fields().include(StaffFields.ID, StaffFields.USERNAME); - List staffMembers = tenantMongoAccess.forServer(server).find(query, Staff.class, CollectionName.STAFF); - Map usernamesById = new HashMap<>(); - for (Staff staff : staffMembers) { - if (staff.getId() != null && staff.getUsername() != null) { - usernamesById.put(staff.getId(), staff.getUsername()); - } - } - return usernamesById; - } - - public List findPunishmentLogs(Server server, Date startDate, int limit, boolean canRollbackOnly) { - Criteria criteria = Criteria.where(AuditLogFields.CREATED).gte(startDate) - .orOperator( - Criteria.where(AuditLogFields.LEVEL).is(LEVEL_MODERATION), - Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("ban|mute|kick|warn", Pattern.CASE_INSENSITIVE)) - ); - - if (canRollbackOnly) { - criteria = criteria.and(AuditLogFields.METADATA_CAN_ROLLBACK).ne(false); - } - - Query query = Query.query(criteria) - .with(Sort.by(Sort.Direction.DESC, AuditLogFields.CREATED)) - .limit(limit); - - return tenantMongoAccess.forServer(server).find(query, AuditLog.class, CollectionName.LOGS); - } - - public List aggregatePunishmentRows(Server server) { - Aggregation aggregation = Aggregation.newAggregation( - Aggregation.match(Criteria.where(PlayerFields.PUNISHMENTS).exists(true).ne(List.of())), - Aggregation.unwind(PlayerFields.PUNISHMENTS), - Aggregation.match(Criteria.where(PlayerFields.PUNISHMENT_TYPE_ORDINAL).ne(0) - .and(PlayerFields.PUNISHMENT_DATA_STATUS).ne(STATUS_UNSTARTED)), - Aggregation.sort(Sort.Direction.DESC, PlayerFields.PUNISHMENT_ISSUED), - Aggregation.project() - .and(PlayerFields.PUNISHMENT_ID).as(ALIAS_PUNISHMENT_ID) - .and(PlayerFields.MINECRAFT_UUID).as(ALIAS_PLAYER_ID) - .and(PlayerFields.PUNISHMENT_TYPE_ORDINAL).as(ALIAS_TYPE_ORDINAL) - .and(PlayerFields.PUNISHMENT_ISSUER_NAME).as(ALIAS_ISSUER_NAME) - .and(PlayerFields.PUNISHMENT_ISSUER_ID).as(ALIAS_ISSUER_ID) - .and(PlayerFields.PUNISHMENT_ISSUED).as(ALIAS_ISSUED) - .and("punishments." + PunishmentFields.STARTED).as(ALIAS_STARTED) - .and("punishments." + PunishmentFields.DATA).as(ALIAS_DATA) - .and("punishments." + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) - .and("punishments." + PunishmentFields.EVIDENCE).as(ALIAS_EVIDENCE) - .and("punishments." + PunishmentFields.ATTACHED_TICKET_IDS).as(ALIAS_ATTACHED_TICKET_IDS) - .and(PlayerFields.USERNAMES).as(ALIAS_USERNAMES) - ).withOptions(AggregationOptions.builder().allowDiskUse(true).build()); - - return tenantMongoAccess.forServer(server) - .aggregate(aggregation, CollectionName.PLAYERS, Document.class) - .getMappedResults(); - } - - public void saveAuditLog(Server server, AuditLog auditLog) { - tenantMongoAccess.forServer(server).save(auditLog, CollectionName.LOGS); - } - - public List readTable(Server server, String collectionName, int limit, int skip) { - Query query = new Query() - .with(Sort.by(Sort.Direction.DESC, "_id")) - .skip(skip) - .limit(limit); - return tenantMongoAccess.forServer(server).find(query, Document.class, collectionName); - } - - public long countCollection(Server server, String collectionName) { - return tenantMongoAccess.forServer(server).count(new Query(), collectionName); - } - - public List findPlayersForRollback(Server server, String staffUsername, String staffId) { - List issuerMatch = new ArrayList<>(); - issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_NAME).regex("^" + Pattern.quote(staffUsername) + "$", "i")); - if (staffId != null) { - issuerMatch.add(Criteria.where(PunishmentFields.ISSUER_ID).is(staffId)); - } - - Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( - new Criteria().orOperator(issuerMatch.toArray(new Criteria[0])) - )); - query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, PlayerFields.PUNISHMENTS); - - return tenantMongoAccess.forServer(server).find(query, Document.class, CollectionName.PLAYERS); - } - - public Document findPlayerByPunishmentId(Server server, String punishmentId) { - Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENT_ID).is(punishmentId)); - query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, - PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); - return tenantMongoAccess.forServer(server).findOne(query, Document.class, CollectionName.PLAYERS); - } - - public List findPlayersForBulkAction(Server server, List typeOrdinals) { - Query query = Query.query(Criteria.where(PlayerFields.PUNISHMENTS).elemMatch( - Criteria.where(PunishmentFields.TYPE_ORDINAL).in(typeOrdinals) - )); - query.fields().include(PlayerFields.ID, PlayerFields.MINECRAFT_UUID, - PlayerFields.USERNAMES, PlayerFields.PUNISHMENTS); - return tenantMongoAccess.forServer(server).find(query, Document.class, CollectionName.PLAYERS); - } - - public void appendPunishmentModification(Server server, String playerId, String punishmentId, - Map modification) { - appendPunishmentModificationWithData(server, playerId, punishmentId, modification, Map.of()); - } - - public void appendPunishmentModificationWithData(Server server, String playerId, String punishmentId, - Map modification, Map dataUpdates) { - Update update = new Update().push(PlayerFields.PUNISHMENT_MODIFICATIONS, modification); - for (Map.Entry entry : dataUpdates.entrySet()) { - update.set("punishments.$.data." + entry.getKey(), entry.getValue()); - } - Query query = Query.query(Criteria.where(PlayerFields.ID).is(playerId).and(PlayerFields.PUNISHMENT_ID).is(punishmentId)); - tenantMongoAccess.forServer(server).updateFirst(query, update, CollectionName.PLAYERS); - } - public List aggregatePunishmentDetails(Server server, List usernames, String staffId, Date startDate) { Criteria criteria = buildIssuerCriteria(usernames, staffId); if (startDate != null) { @@ -271,10 +129,10 @@ public List aggregatePunishmentDetails(Server server, List use .and(PlayerFields.MINECRAFT_UUID).as(ALIAS_PLAYER_ID) .and(PlayerFields.PUNISHMENT_TYPE_ORDINAL).as(ALIAS_TYPE_ORDINAL) .and(PlayerFields.PUNISHMENT_ISSUED).as(ALIAS_ISSUED) - .and("punishments." + PunishmentFields.STARTED).as(ALIAS_STARTED) + .and(PUNISHMENTS_PREFIX + PunishmentFields.STARTED).as(ALIAS_STARTED) .and(PlayerFields.PUNISHMENT_DATA_REASON).as(ALIAS_REASON) .and(PlayerFields.PUNISHMENT_DATA_DURATION).as(ALIAS_DURATION) - .and("punishments." + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) + .and(PUNISHMENTS_PREFIX + PunishmentFields.MODIFICATIONS).as(ALIAS_MODIFICATIONS) .and(PlayerFields.USERNAMES).as(ALIAS_USERNAMES) ); @@ -320,6 +178,34 @@ public List aggregateTicketDetails(Server server, String username, Dat .getMappedResults(); } + public List aggregateTicketResponseTimesByStaff(Server server, Date startDate) { + Criteria replyCriteria = Criteria.where(TicketFields.REPLY_STAFF).is(true); + if (startDate != null) { + replyCriteria = replyCriteria.and(TicketFields.REPLY_CREATED).gte(startDate); + } + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.unwind(TicketFields.REPLIES), + Aggregation.match(replyCriteria), + Aggregation.project() + .and(StringOperators.ToLower.lowerValueOf(TicketFields.REPLY_NAME)).as(ALIAS_STAFF_KEY) + .and(TicketFields.ID).as(ALIAS_TICKET_ID) + .and(TicketFields.CREATED).as(ALIAS_TICKET_CREATED) + .and(TicketFields.REPLY_CREATED).as(ALIAS_REPLY_CREATED), + Aggregation.group(ALIAS_STAFF_KEY, ALIAS_TICKET_ID) + .first(ALIAS_TICKET_CREATED).as(ALIAS_TICKET_CREATED) + .min(ALIAS_REPLY_CREATED).as(ALIAS_FIRST_REPLY), + Aggregation.project() + .and("_id." + ALIAS_STAFF_KEY).as(ALIAS_STAFF) + .and(ALIAS_TICKET_CREATED).as(ALIAS_TICKET_CREATED) + .and(ALIAS_FIRST_REPLY).as(ALIAS_FIRST_REPLY) + ); + + return tenantMongoAccess.forServer(server) + .aggregate(aggregation, CollectionName.TICKETS, StaffTicketResponseTime.class) + .getMappedResults(); + } + public List aggregateDailyPunishmentCounts(Server server, List usernames, String staffId, Date startDate) { Criteria criteria = buildIssuerCriteria(usernames, staffId); if (startDate != null) { @@ -375,28 +261,11 @@ public List aggregatePunishmentTypeBreakdown(Server server, .getMappedResults(); } - public long countEvidenceUploads(Server server, String username, Date startDate) { - Criteria baseCriteria = Criteria.where(AuditLogFields.SOURCE).is(username); - if (startDate != null) { - baseCriteria = baseCriteria.and(AuditLogFields.CREATED).gte(startDate); - } - - Query query = Query.query( - baseCriteria.orOperator( - Criteria.where(AuditLogFields.DESCRIPTION).regex(Pattern.compile("evidence|upload|file", Pattern.CASE_INSENSITIVE)), - Criteria.where(AuditLogFields.LEVEL) - .is(LEVEL_INFO) - .and(AuditLogFields.DESCRIPTION) - .regex(Pattern.compile("uploaded|attachment", Pattern.CASE_INSENSITIVE)) - ) - ); - - return tenantMongoAccess.forServer(server).count(query, AuditLog.class, CollectionName.LOGS); - } - public record IdCountResult(String id, int count) {} public record OrdinalCountResult(Integer id, int count) {} public record StaffActivityResult(String id, int totalActions, int ticketActions, int moderationActions, Date lastActive) {} + + public record StaffTicketResponseTime(String staff, Date ticketCreated, Date firstReply) {} } 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 21e714c..7a6e8e2 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 @@ -4,9 +4,9 @@ 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.email.EmailAddressUtil; 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; @@ -131,6 +131,12 @@ public Map countByRoleId(Server server) { return counts; } + public List findAllStaff(Server server) { + Query query = new Query(); + query.fields().include(StaffFields.ID, StaffFields.USERNAME, StaffFields.EMAIL, StaffFields.ROLE_ID, StaffFields.ASSIGNED_MINECRAFT_USERNAME, StaffFields.UPDATED_AT); + return find(server, query); + } + public Map findUsernamesByIds(Server server, Set ids) { if (ids == null || ids.isEmpty()) { return Map.of(); @@ -184,7 +190,7 @@ public boolean deactivateSubscription(Server server, String email, String ticket return updateFirst(server, query, update).getModifiedCount() > 0; } - public boolean markSubscriptionRead(Server server, String email, String ticketId, java.util.Date lastReadAt) { + public boolean markSubscriptionRead(Server server, String email, String ticketId, Date lastReadAt) { Query query = Query.query( Criteria.where(StaffFields.EMAIL).is(email) .and(StaffFields.SUBSCRIBED_TICKET_TICKET_ID).is(ticketId) @@ -236,7 +242,9 @@ public void clearPendingTwoFactorDelivery(Server server) { } public List findByUsernames(Server server, Collection usernames) { - if (usernames == null || usernames.isEmpty()) return List.of(); + 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 a51a387..88103db 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 @@ -2,11 +2,11 @@ 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.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -53,7 +53,6 @@ public boolean existsByNameIgnoreCaseExcludingId(Server server, String roleName, return exists(server, Query.query(criteria)); } - public void updateOrder(Server server, String roleId, int order) { Update update = new Update(); update.set(StaffRoleFields.ORDER, order); @@ -112,25 +111,19 @@ public List findByIds(Server server, Collection 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(); + bulkUpdateByIds(server, new ArrayList<>(orderById.keySet()), + (id, index) -> new Update().set(StaffRoleFields.ORDER, orderById.get(id))); } public void bulkRepairOrderFromZero(Server server, Map orderById) { - if (orderById.isEmpty()) return; + 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()) + Query query = Query.query(Criteria.where(StaffRoleFields.ID).is(entry.getKey()) .and(StaffRoleFields.ORDER).is(0) .and(StaffRoleFields.IS_DEFAULT).is(false)); Update update = new Update().set(StaffRoleFields.ORDER, entry.getValue()); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java index 862dc67..11591c7 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StorageFileMongoRepository.java @@ -8,9 +8,9 @@ import gg.modl.backend.storage.data.StorageFileDocument; import java.util.ArrayList; import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.HashMap; import java.util.Optional; import org.bson.Document; import org.springframework.data.domain.Sort; @@ -22,6 +22,7 @@ @Repository public class StorageFileMongoRepository extends AbstractServerMongoRepository { + private static final String ALIAS_TOTAL_SIZE = "totalSize"; public StorageFileMongoRepository(TenantMongoAccess tenantMongoAccess) { super(StorageFileDocument.class, CollectionName.STORAGE_FILES, tenantMongoAccess); @@ -83,19 +84,19 @@ public long sumSizeByKeyPrefixesSince(Server server, List prefixes, Date Aggregation aggregation = Aggregation.newAggregation( Aggregation.match(match), - Aggregation.group().sum(StorageFileDocumentFields.SIZE).as("totalSize") + Aggregation.group().sum(StorageFileDocumentFields.SIZE).as(ALIAS_TOTAL_SIZE) ); Document result = aggregate(server, aggregation, Document.class).getUniqueMappedResult(); if (result == null) { return 0L; } - return result.get("totalSize", Number.class).longValue(); + return result.get(ALIAS_TOTAL_SIZE, Number.class).longValue(); } public Map aggregateStorageByCategory(Server server) { Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group(StorageFileDocumentFields.CATEGORY).sum(StorageFileDocumentFields.SIZE).as("totalSize") + Aggregation.group(StorageFileDocumentFields.CATEGORY).sum(StorageFileDocumentFields.SIZE).as(ALIAS_TOTAL_SIZE) ); AggregationResults results = aggregate(server, aggregation, Document.class); @@ -110,7 +111,7 @@ public Map aggregateStorageByCategory(Server server) { for (Document doc : results.getMappedResults()) { String category = doc.getString("_id"); - long totalSize = doc.get("totalSize", Number.class).longValue(); + long totalSize = doc.get(ALIAS_TOTAL_SIZE, Number.class).longValue(); byType.put(category, totalSize); } @@ -119,7 +120,7 @@ public Map aggregateStorageByCategory(Server server) { public long sumTotalSize(Server server) { Aggregation aggregation = Aggregation.newAggregation( - Aggregation.group().sum(StorageFileDocumentFields.SIZE).as("totalSize") + Aggregation.group().sum(StorageFileDocumentFields.SIZE).as(ALIAS_TOTAL_SIZE) ); AggregationResults results = aggregate(server, aggregation, Document.class); @@ -128,7 +129,7 @@ public long sumTotalSize(Server server) { if (result == null) { return 0L; } - return result.get("totalSize", Number.class).longValue(); + return result.get(ALIAS_TOTAL_SIZE, Number.class).longValue(); } private String escapeRegex(String input) { diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java index 9cbe8e7..c65fb95 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/StripeWebhookEventMongoRepository.java @@ -15,15 +15,15 @@ @Repository public class StripeWebhookEventMongoRepository extends AbstractGlobalMongoRepository { - public StripeWebhookEventMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(StripeWebhookEvent.class, CollectionName.STRIPE_WEBHOOK_EVENTS, tenantMongoAccess); - } - private static final long PROCESSING_STALE_MS = 15L * 60L * 1000L; private static final String STATUS_PROCESSING = "processing"; private static final String STATUS_PROCESSED = "processed"; private static final String STATUS_FAILED = "failed"; + public StripeWebhookEventMongoRepository(TenantMongoAccess tenantMongoAccess) { + super(StripeWebhookEvent.class, CollectionName.STRIPE_WEBHOOK_EVENTS, tenantMongoAccess); + } + public boolean markProcessing(String eventId, String eventType, Date processingAt) { try { Date staleBefore = new Date(processingAt.getTime() - PROCESSING_STALE_MS); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java index 883db4c..874c85d 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SystemConfigMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.SystemConfig; +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.SystemConfigFields; @@ -12,10 +13,9 @@ @Repository public class SystemConfigMongoRepository extends AbstractGlobalMongoRepository { public static final String MAIN_CONFIG_ID = "main_config"; - private static final String COLLECTION_NAME = "system_config"; public SystemConfigMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SystemConfig.class, COLLECTION_NAME, tenantMongoAccess); + super(SystemConfig.class, CollectionName.SYSTEM_CONFIG, tenantMongoAccess); } public Optional findMainConfig() { diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java index dab0133..c78312a 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SystemLogMongoRepository.java @@ -3,8 +3,8 @@ import static gg.modl.backend.database.mongo.MongoAggregationResults.extractFacetCount; import gg.modl.backend.admin.data.SystemLog; +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.SystemLogFields; import java.util.ArrayList; @@ -20,11 +20,10 @@ import org.springframework.data.mongodb.core.query.Query; import org.springframework.data.mongodb.core.query.Update; import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; @Repository public class SystemLogMongoRepository extends AbstractGlobalMongoRepository { - public static final String COLLECTION_NAME = "system_logs"; - private static final String ALIAS_DATE = "date"; private static final String ALIAS_LEVEL = "level"; private static final String ALIAS_COUNT = "count"; @@ -40,7 +39,7 @@ public class SystemLogMongoRepository extends AbstractGlobalMongoRepository criteriaList = new ArrayList<>(); - if (hasText(level)) { + if (StringUtils.hasText(level)) { criteriaList.add(Criteria.where(SystemLogFields.LEVEL).is(level)); } - if (hasText(source)) { + if (StringUtils.hasText(source)) { criteriaList.add(Criteria.where(SystemLogFields.SOURCE).is(source)); } - if (hasText(serverId)) { + if (StringUtils.hasText(serverId)) { criteriaList.add(Criteria.where(SystemLogFields.SERVER_ID).is(serverId)); } - if (hasText(category)) { + if (StringUtils.hasText(category)) { criteriaList.add(Criteria.where(SystemLogFields.CATEGORY).is(category)); } if (resolved != null) { criteriaList.add(Criteria.where(SystemLogFields.RESOLVED).is(RESOLVED_TRUE.equals(resolved))); } - if (hasText(search)) { + if (StringUtils.hasText(search)) { criteriaList.add(Criteria.where(SystemLogFields.MESSAGE).regex(Pattern.quote(search), "i")); } if (startDate != null || endDate != null) { @@ -140,10 +139,6 @@ private Query buildLogsQuery( return query; } - private boolean hasText(String value) { - return value != null && !value.isBlank(); - } - private Sort.Direction resolveDirection(String order) { return ORDER_DESC.equalsIgnoreCase(order) ? Sort.Direction.DESC : Sort.Direction.ASC; } @@ -176,11 +171,11 @@ public long countLogs( } public List findDistinctSources() { - return globalTemplate().findDistinct(new Query(), SystemLogFields.SOURCE, COLLECTION_NAME, String.class); + return globalTemplate().findDistinct(new Query(), SystemLogFields.SOURCE, CollectionName.SYSTEM_LOGS, String.class); } public List findDistinctCategories() { - return globalTemplate().findDistinct(new Query(), SystemLogFields.CATEGORY, COLLECTION_NAME, String.class); + return globalTemplate().findDistinct(new Query(), SystemLogFields.CATEGORY, CollectionName.SYSTEM_LOGS, String.class); } public SystemLog resolveById(String id, String resolvedBy, Date resolvedAt) { @@ -250,7 +245,7 @@ public MonitoringLogStats aggregateMonitoringLogStats(Date oneDayAgo) { )); List pipeline = List.of(new Document("$facet", facet)); - List results = globalTemplate().getCollection(COLLECTION_NAME) + List results = globalTemplate().getCollection(CollectionName.SYSTEM_LOGS) .aggregate(pipeline) .into(new ArrayList<>()); diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java index d8739ba..6379a39 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/SystemPromptMongoRepository.java @@ -1,6 +1,7 @@ package gg.modl.backend.database.mongo.repository; import gg.modl.backend.admin.data.SystemPrompt; +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.SystemPromptFields; @@ -14,10 +15,9 @@ @Repository public class SystemPromptMongoRepository extends AbstractGlobalMongoRepository { - private static final String COLLECTION_NAME = "systemprompts"; public SystemPromptMongoRepository(TenantMongoAccess tenantMongoAccess) { - super(SystemPrompt.class, COLLECTION_NAME, tenantMongoAccess); + super(SystemPrompt.class, CollectionName.SYSTEM_PROMPTS, tenantMongoAccess); } public Optional findActive() { 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 4d725f9..f532b13 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 @@ -409,7 +409,7 @@ public Ticket saveAppeal(Server server, Ticket appeal) { } public Ticket insertTicket(Server server, Ticket ticket) { - return insert(serverTemplate(server), ticket); + return insertEntity(server, ticket); } public void pushReply(Server server, String ticketId, TicketReply reply) { @@ -578,7 +578,9 @@ 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; + if (ticketIds == null || ticketIds.isEmpty()) { + return; + } Query query = Query.query( Criteria.where(TicketFields.ID).in(ticketIds) .and(TicketFields.LOCKED).ne(true) diff --git a/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java b/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java index d8ad96a..19f5536 100644 --- a/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java +++ b/src/main/java/gg/modl/backend/database/mongo/repository/TrainingSegmentRepository.java @@ -6,21 +6,19 @@ import gg.modl.backend.replay.data.TrainingSegmentDocument; import java.util.Collection; import java.util.List; +import lombok.RequiredArgsConstructor; 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.stereotype.Repository; @Repository +@RequiredArgsConstructor public class TrainingSegmentRepository { private static final String COLLECTION = CollectionName.TRAINING_SEGMENTS; private final TenantMongoAccess tenantMongoAccess; - public TrainingSegmentRepository(TenantMongoAccess tenantMongoAccess) { - this.tenantMongoAccess = tenantMongoAccess; - } - public void save(TrainingSegmentDocument doc) { nonTenantTrainingStore().save(doc, COLLECTION); } diff --git a/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java b/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java index a8ed501..17cab9e 100644 --- a/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java +++ b/src/main/java/gg/modl/backend/dev/DevTenantSeeder.java @@ -1,6 +1,6 @@ package gg.modl.backend.dev; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.email.EmailAddressUtil; import gg.modl.backend.infrastructure.config.ModlDevProperties; import gg.modl.backend.infrastructure.config.ModlProperties; @@ -25,7 +25,7 @@ public class DevTenantSeeder implements ApplicationRunner { private final ModlProperties modlProperties; private final ModlDevProperties devProperties; private final ServerService serverService; - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; private final ServerProvisioningService provisioningService; @Override @@ -44,7 +44,7 @@ public void run(ApplicationArguments args) { String customDomain = resolveCustomDomain(serverDomain); String adminEmail = normalizeEmail(devProperties.getSeedAdminEmail()); - Server server = serverRepository.findByCustomDomain(customDomain).orElse(null); + Server server = serverLookupRepository.findByCustomDomain(customDomain).orElse(null); if (server == null) { server = serverService.createServer(devProperties.getServerName(), customDomain, adminEmail, null, ServerPlan.PREMIUM); log.warn("[DevSeed] Created test tenant server '{}' (customDomain={})", devProperties.getServerName(), customDomain); @@ -62,7 +62,7 @@ public void run(ApplicationArguments args) { } server.setUpdatedAt(new Date()); - serverRepository.saveEntity(server); + serverLookupRepository.saveEntity(server); serverService.evictAllServerCaches(); log.warn("======================================================================"); diff --git a/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java b/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java index 4d8fe9b..55f1548 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java +++ b/src/main/java/gg/modl/backend/homepage/controller/HomepageProtoMapper.java @@ -1,12 +1,15 @@ package gg.modl.backend.homepage.controller; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.addAll; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; import gg.modl.backend.homepage.data.HomepageCard; -import gg.modl.backend.homepage.dto.response.HomepageCardResponse; import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; +import gg.modl.backend.knowledgebase.data.KnowledgebaseCategory; import gg.modl.proto.modl.v1.HomepageCardMutationResponse; +import gg.modl.proto.modl.v1.HomepageCardResponse; import gg.modl.proto.modl.v1.PanelHomepageCardResponse; import gg.modl.proto.modl.v1.PanelHomepageCardsResponse; import gg.modl.proto.modl.v1.PublicHomepageCardsResponse; @@ -26,7 +29,7 @@ public PanelHomepageCardResponse toPanelCardResponse(HomepageCard card) { return panelCardBuilder(card).build(); } - public PublicHomepageCardsResponse toPublicCardsResponse(List cards) { + public PublicHomepageCardsResponse toPublicCardsResponse(List cards) { PublicHomepageCardsResponse.Builder builder = PublicHomepageCardsResponse.newBuilder(); addAll(cards, this::toPublicCard, builder::addCards); return builder.build(); @@ -73,51 +76,47 @@ private PanelHomepageCardResponse.Builder panelCardBuilder(HomepageCard card) { return builder; } - private gg.modl.proto.modl.v1.HomepageCardResponse toPublicCard(HomepageCardResponse card) { - gg.modl.proto.modl.v1.HomepageCardResponse.Builder builder = - gg.modl.proto.modl.v1.HomepageCardResponse.newBuilder() - .setId(nullToEmpty(card.id())) - .setTitle(nullToEmpty(card.title())) - .setDescription(nullToEmpty(card.description())) - .setIcon(nullToEmpty(card.icon())) - .setIconColor(nullToEmpty(card.iconColor())) - .setActionType(nullToEmpty(card.actionType())) - .setActionUrl(nullToEmpty(card.actionUrl())) - .setActionButtonText(nullToEmpty(card.actionButtonText())) - .setCategoryId(nullToEmpty(card.categoryId())) - .setBackgroundColor(nullToEmpty(card.backgroundColor())) - .setOrdinal(card.ordinal()) - .setIsEnabled(card.isEnabled()); - if (card.createdAt() != null) { - builder.setCreatedAt(toTimestamp(card.createdAt())); + private HomepageCardResponse toPublicCard(HomepageCardService.EnrichedCardWithArticles enriched) { + HomepageCard card = enriched.card(); + HomepageCardResponse.Builder builder = HomepageCardResponse.newBuilder() + .setId(nullToEmpty(card.getId())) + .setTitle(nullToEmpty(card.getTitle())) + .setDescription(nullToEmpty(card.getDescription())) + .setIcon(nullToEmpty(card.getIcon())) + .setIconColor(nullToEmpty(card.getIconColor())) + .setActionType(nullToEmpty(card.getActionType())) + .setActionUrl(nullToEmpty(card.getActionUrl())) + .setActionButtonText(nullToEmpty(card.getActionButtonText())) + .setCategoryId(nullToEmpty(card.getCategoryId())) + .setBackgroundColor(nullToEmpty(card.getBackgroundColor())) + .setOrdinal(card.getOrdinal()) + .setIsEnabled(card.isEnabled()); + if (card.getCreatedAt() != null) { + builder.setCreatedAt(toTimestamp(card.getCreatedAt())); } - if (card.updatedAt() != null) { - builder.setUpdatedAt(toTimestamp(card.updatedAt())); + if (card.getUpdatedAt() != null) { + builder.setUpdatedAt(toTimestamp(card.getUpdatedAt())); } - HomepageCardResponse.EmbeddedCategory category = card.category(); + KnowledgebaseCategory category = enriched.category(); if (category != null) { - gg.modl.proto.modl.v1.HomepageCardResponse.EmbeddedCategory.Builder embedded = - gg.modl.proto.modl.v1.HomepageCardResponse.EmbeddedCategory.newBuilder() - .setId(nullToEmpty(category.id())) - .setName(nullToEmpty(category.name())) - .setSlug(nullToEmpty(category.slug())) - .setDescription(nullToEmpty(category.description())); - addAll(category.articles(), this::toArticleStub, embedded::addArticles); + HomepageCardResponse.EmbeddedCategory.Builder embedded = + HomepageCardResponse.EmbeddedCategory.newBuilder() + .setId(nullToEmpty(category.getId())) + .setName(nullToEmpty(category.getName())) + .setSlug(nullToEmpty(category.getSlug())) + .setDescription(nullToEmpty(category.getDescription())); + addAll(enriched.articles(), this::toArticleStub, embedded::addArticles); builder.setCategory(embedded); } return builder.build(); } - private gg.modl.proto.modl.v1.HomepageCardResponse.ArticleStub toArticleStub(HomepageCardResponse.ArticleStub stub) { - return gg.modl.proto.modl.v1.HomepageCardResponse.ArticleStub.newBuilder() - .setId(nullToEmpty(stub.id())) - .setTitle(nullToEmpty(stub.title())) - .setSlug(nullToEmpty(stub.slug())) - .setOrdinal(stub.ordinal()) + private HomepageCardResponse.ArticleStub toArticleStub(KnowledgebaseArticle article) { + return HomepageCardResponse.ArticleStub.newBuilder() + .setId(nullToEmpty(article.getId())) + .setTitle(nullToEmpty(article.getTitle())) + .setSlug(nullToEmpty(article.getSlug())) + .setOrdinal(article.getOrdinal()) .build(); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } 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 8396f46..287edb4 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java +++ b/src/main/java/gg/modl/backend/homepage/controller/PanelHomepageCardController.java @@ -2,6 +2,7 @@ import gg.modl.backend.homepage.data.HomepageCard; import gg.modl.backend.homepage.service.HomepageCardService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.realtime.publish.RealtimeEventPublisher; @@ -28,6 +29,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_HOMEPAGE_CARDS) +@RequiresPanelPermission(view = "admin.settings.view.content", modify = "admin.settings.modify.content") @RequiredArgsConstructor public class PanelHomepageCardController { private final HomepageCardService cardService; 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 6dd0f15..c19bdf3 100644 --- a/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java +++ b/src/main/java/gg/modl/backend/homepage/controller/PublicHomepageCardController.java @@ -1,13 +1,11 @@ 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; @@ -24,13 +22,6 @@ public class PublicHomepageCardController { @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)); + return ResponseEntity.ok(mapper.toPublicCardsResponse(cardService.getVisibleCardsEnrichedWithArticles(server))); } } 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 deleted file mode 100644 index b9cf022..0000000 --- a/src/main/java/gg/modl/backend/homepage/dto/response/HomepageCardResponse.java +++ /dev/null @@ -1,77 +0,0 @@ -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 3ba8c72..5deae07 100644 --- a/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java +++ b/src/main/java/gg/modl/backend/homepage/service/HomepageCardService.java @@ -15,6 +15,7 @@ import java.util.Map; import java.util.Optional; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +25,8 @@ @RequiredArgsConstructor @Slf4j public class HomepageCardService { + private static final String CATEGORY_DROPDOWN_ACTION_TYPE = "category_dropdown"; + private final HomepageCardMongoRepository homepageCardRepository; private final KnowledgebaseCategoryService categoryService; private final KnowledgebaseArticleService articleService; @@ -35,16 +38,10 @@ public List getVisibleCards(Server 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(); + List categoryIds = distinctCategoryIds(cards, + card -> CATEGORY_DROPDOWN_ACTION_TYPE.equals(card.getActionType())); - Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) - .stream() - .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); + Map categoriesById = categoriesByIdMap(server, categoryIds); Map> articlesByCategoryId = articleService.getVisibleArticlesGroupedByCategoryIds(server, categoryIds); @@ -52,7 +49,7 @@ public List getVisibleCardsEnrichedWithArticles(Server return cards.stream() .map(card -> { KnowledgebaseCategory category = null; - if ("category_dropdown".equals(card.getActionType()) + if (CATEGORY_DROPDOWN_ACTION_TYPE.equals(card.getActionType()) && card.getCategoryId() != null && !card.getCategoryId().isEmpty()) { category = categoriesById.get(card.getCategoryId()); } @@ -121,15 +118,9 @@ public void reorderCards(Server server, List 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(); + List categoryIds = distinctCategoryIds(cards, card -> true); - Map categoriesById = categoryService.getCategoriesByIds(server, categoryIds) - .stream() - .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); + Map categoriesById = categoriesByIdMap(server, categoryIds); return cards.stream() .map(card -> { @@ -149,6 +140,21 @@ public List getAllCards(Server server) { return homepageCardRepository.findAllOrdered(server); } + private List distinctCategoryIds(List cards, Predicate filter) { + return cards.stream() + .filter(filter) + .map(HomepageCard::getCategoryId) + .filter(id -> id != null && !id.isEmpty()) + .distinct() + .toList(); + } + + private Map categoriesByIdMap(Server server, List categoryIds) { + return categoryService.getCategoriesByIds(server, categoryIds) + .stream() + .collect(Collectors.toMap(KnowledgebaseCategory::getId, Function.identity())); + } + public record EmbeddedCategory(String id, String name, String slug) {} public record EnrichedCard( diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java new file mode 100644 index 0000000..bbdb0e6 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/AppealReplyPolicy.java @@ -0,0 +1,21 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.role.service.PermissionService; + +public enum AppealReplyPolicy implements PanelAccessPolicy { + INSTANCE; + + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return false; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + if (request.isReadOnly()) { + return permissions.has(PermissionService.TICKET_VIEW_ALL); + } + return permissions.has(PermissionService.APPEAL_MODIFY) + || permissions.has(PermissionService.TICKET_REPLY_ALL); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java new file mode 100644 index 0000000..3f10554 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicy.java @@ -0,0 +1,7 @@ +package gg.modl.backend.infrastructure.authorization; + +public interface PanelAccessPolicy { + boolean permitsWithoutRole(PanelAccessRequest request); + + boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions); +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java new file mode 100644 index 0000000..e4a1880 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessPolicyResolver.java @@ -0,0 +1,98 @@ +package gg.modl.backend.infrastructure.authorization; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerExecutionChain; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.ServletRequestPathUtils; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PanelAccessPolicyResolver { + private final ObjectProvider handlerMappingProvider; + + public List resolvePolicies(HttpServletRequest request) { + RequestMappingHandlerMapping handlerMapping = handlerMappingProvider.getObject(); + boolean parsedHere = ensureParsedRequestPath(handlerMapping, request); + try { + HandlerExecutionChain chain = handlerMapping.getHandler(request); + if (chain != null && chain.getHandler() instanceof HandlerMethod handlerMethod) { + return resolve(handlerMethod).map(List::of).orElseGet(List::of); + } + return List.of(); + } catch (HttpRequestMethodNotSupportedException methodNotSupported) { + return siblingPolicies(handlerMapping, request); + } catch (Exception exception) { + log.debug("Panel handler resolution failed for {} {}", request.getMethod(), request.getRequestURI(), exception); + return List.of(); + } finally { + if (parsedHere) { + ServletRequestPathUtils.clearParsedRequestPath(request); + } + } + } + + public Optional resolve(HandlerMethod handlerMethod) { + return Optional.ofNullable(findAnnotation(handlerMethod)).map(PanelAccessPolicyResolver::toPolicy); + } + + private List siblingPolicies(RequestMappingHandlerMapping handlerMapping, HttpServletRequest request) { + List policies = new ArrayList<>(); + handlerMapping.getHandlerMethods().forEach((mappingInfo, handlerMethod) -> { + if (matchesPath(mappingInfo, request)) { + resolve(handlerMethod).ifPresent(policies::add); + } + }); + return policies; + } + + private static boolean matchesPath(RequestMappingInfo mappingInfo, HttpServletRequest request) { + PathPatternsRequestCondition pathPatterns = mappingInfo.getPathPatternsCondition(); + return pathPatterns != null && pathPatterns.getMatchingCondition(request) != null; + } + + private boolean ensureParsedRequestPath(RequestMappingHandlerMapping handlerMapping, HttpServletRequest request) { + if (handlerMapping.getPatternParser() == null || ServletRequestPathUtils.hasParsedRequestPath(request)) { + return false; + } + ServletRequestPathUtils.parseAndCache(request); + return true; + } + + private static RequiresPanelPermission findAnnotation(HandlerMethod handlerMethod) { + RequiresPanelPermission methodAnnotation = handlerMethod.getMethodAnnotation(RequiresPanelPermission.class); + if (methodAnnotation != null) { + return methodAnnotation; + } + return AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(), RequiresPanelPermission.class); + } + + private static PanelAccessPolicy toPolicy(RequiresPanelPermission annotation) { + return switch (annotation.rule()) { + case PERMIT_ALL -> PermitAllPolicy.INSTANCE; + case PLAYER_ACCESS -> PlayerAccessPolicy.INSTANCE; + case APPEAL_REPLY -> AppealReplyPolicy.INSTANCE; + case REQUIRE_PERMISSION -> new ReadWritePermissionPolicy(viewPermission(annotation), modifyPermission(annotation)); + }; + } + + private static String viewPermission(RequiresPanelPermission annotation) { + return annotation.view().isEmpty() ? annotation.value() : annotation.view(); + } + + private static String modifyPermission(RequiresPanelPermission annotation) { + return annotation.modify().isEmpty() ? annotation.value() : annotation.modify(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java new file mode 100644 index 0000000..74b12ee --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRequest.java @@ -0,0 +1,7 @@ +package gg.modl.backend.infrastructure.authorization; + +public record PanelAccessRequest(String method, String path) { + public boolean isReadOnly() { + return "GET".equalsIgnoreCase(method) || "HEAD".equalsIgnoreCase(method); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java new file mode 100644 index 0000000..0c62fee --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAccessRule.java @@ -0,0 +1,8 @@ +package gg.modl.backend.infrastructure.authorization; + +public enum PanelAccessRule { + REQUIRE_PERMISSION, + PERMIT_ALL, + PLAYER_ACCESS, + APPEAL_REPLY +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java new file mode 100644 index 0000000..499cd56 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelAuthorizationBootstrapValidator.java @@ -0,0 +1,52 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.infrastructure.rest.RouteGroups; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.condition.PathPatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +@Component +@RequiredArgsConstructor +public class PanelAuthorizationBootstrapValidator implements SmartInitializingSingleton { + private final ObjectProvider handlerMappingProvider; + private final PanelAccessPolicyResolver policyResolver; + + @Override + public void afterSingletonsInstantiated() { + List unguarded = new ArrayList<>(); + handlerMappingProvider.getObject().getHandlerMethods().forEach((mappingInfo, handlerMethod) -> { + if (requiresPanelAuthorization(mappingInfo) && policyResolver.resolve(handlerMethod).isEmpty()) { + unguarded.add(describe(mappingInfo, handlerMethod)); + } + }); + if (!unguarded.isEmpty()) { + throw new IllegalStateException( + "Panel endpoints without a @RequiresPanelPermission policy (fail-closed default would deny them): " + unguarded); + } + } + + private boolean requiresPanelAuthorization(RequestMappingInfo mappingInfo) { + return patternsOf(mappingInfo).stream().anyMatch(this::isGuardedPanelPattern); + } + + private boolean isGuardedPanelPattern(String pattern) { + return RouteGroups.isPanelPrefix(pattern) && !RouteGroups.isPanelAuthArea(pattern); + } + + private Set patternsOf(RequestMappingInfo mappingInfo) { + PathPatternsRequestCondition pathPatterns = mappingInfo.getPathPatternsCondition(); + return pathPatterns != null ? pathPatterns.getPatternValues() : Set.of(); + } + + private String describe(RequestMappingInfo mappingInfo, HandlerMethod handlerMethod) { + return patternsOf(mappingInfo) + " -> " + handlerMethod.getShortLogMessage(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java new file mode 100644 index 0000000..75e7bba --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PanelPrincipalPermissions.java @@ -0,0 +1,15 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import org.jetbrains.annotations.Nullable; + +public record PanelPrincipalPermissions(Server server, @Nullable String roleId, PermissionService permissionService) { + public boolean has(String permission) { + return permissionService.hasPermission(server, roleId, permission); + } + + public boolean hasAnyWithPrefix(String prefix) { + return permissionService.hasAnyPermissionWithPrefix(server, roleId, prefix); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java new file mode 100644 index 0000000..953e42e --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PermitAllPolicy.java @@ -0,0 +1,15 @@ +package gg.modl.backend.infrastructure.authorization; + +public enum PermitAllPolicy implements PanelAccessPolicy { + INSTANCE; + + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return true; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + return true; + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java new file mode 100644 index 0000000..3496679 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/PlayerAccessPolicy.java @@ -0,0 +1,24 @@ +package gg.modl.backend.infrastructure.authorization; + +import gg.modl.backend.role.service.PermissionService; + +public enum PlayerAccessPolicy implements PanelAccessPolicy { + INSTANCE; + + private static final String PUNISHMENT_APPLY_PREFIX = "punishment.apply."; + + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return false; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + if (request.isReadOnly()) { + return permissions.has(PermissionService.PUNISHMENT_VIEW) + || permissions.has(PermissionService.PUNISHMENT_MODIFY) + || permissions.hasAnyWithPrefix(PUNISHMENT_APPLY_PREFIX); + } + return permissions.has(PermissionService.PUNISHMENT_MODIFY); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java b/src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java new file mode 100644 index 0000000..3b9716e --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/ReadWritePermissionPolicy.java @@ -0,0 +1,13 @@ +package gg.modl.backend.infrastructure.authorization; + +public record ReadWritePermissionPolicy(String viewPermission, String modifyPermission) implements PanelAccessPolicy { + @Override + public boolean permitsWithoutRole(PanelAccessRequest request) { + return false; + } + + @Override + public boolean permitsWithRole(PanelAccessRequest request, PanelPrincipalPermissions permissions) { + return permissions.has(request.isReadOnly() ? viewPermission : modifyPermission); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java b/src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java new file mode 100644 index 0000000..377394a --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/authorization/RequiresPanelPermission.java @@ -0,0 +1,18 @@ +package gg.modl.backend.infrastructure.authorization; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface RequiresPanelPermission { + String value() default ""; + + String view() default ""; + + String modify() default ""; + + PanelAccessRule rule() default PanelAccessRule.REQUIRE_PERMISSION; +} diff --git a/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java b/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java index 263498c..c7618fd 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/AuthCodeHashSecretGuard.java @@ -3,12 +3,12 @@ import gg.modl.backend.auth.AuthConfiguration; import gg.modl.backend.ticket.config.TicketEmailVerificationConfiguration; 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; +import org.springframework.util.StringUtils; @Component @RequiredArgsConstructor @@ -21,10 +21,9 @@ public class AuthCodeHashSecretGuard { @PostConstruct public void validate() { - boolean isProductionProfile = Arrays.stream(environment.getActiveProfiles()) - .anyMatch(profile -> PRODUCTION_PROFILES.contains(profile.toLowerCase())); - boolean authSecretMissing = isBlank(authConfiguration.getCodeHashSecret()); - boolean ticketSecretMissing = isBlank(ticketEmailVerificationConfiguration.getCodeHashSecret()); + boolean isProductionProfile = ProfileEnvironment.hasAnyActiveProfile(environment, PRODUCTION_PROFILES); + boolean authSecretMissing = !StringUtils.hasText(authConfiguration.getCodeHashSecret()); + boolean ticketSecretMissing = !StringUtils.hasText(ticketEmailVerificationConfiguration.getCodeHashSecret()); if (isProductionProfile && (authSecretMissing || ticketSecretMissing)) { throw new IllegalStateException( @@ -44,8 +43,4 @@ public void validate() { log.warn("======================================================================"); } } - - private static boolean isBlank(String value) { - return value == null || value.isBlank(); - } } 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 656029f..8ad2cb6 100644 --- a/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java +++ b/src/main/java/gg/modl/backend/infrastructure/config/DevelopmentModeGuard.java @@ -1,7 +1,6 @@ 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; @@ -22,8 +21,7 @@ public void validate() { return; } - boolean isDevProfile = Arrays.stream(environment.getActiveProfiles()) - .anyMatch(profile -> DEV_PROFILES.contains(profile.toLowerCase())); + boolean isDevProfile = ProfileEnvironment.hasAnyActiveProfile(environment, DEV_PROFILES); boolean explicitlyAllowed = environment.getProperty("MODL_ALLOW_DEV_MODE", Boolean.class, false); if (!isDevProfile && !explicitlyAllowed) { diff --git a/src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java b/src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java new file mode 100644 index 0000000..d99e6cd --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/config/ProfileEnvironment.java @@ -0,0 +1,15 @@ +package gg.modl.backend.infrastructure.config; + +import java.util.Arrays; +import java.util.Set; +import org.springframework.core.env.Environment; + +public final class ProfileEnvironment { + private ProfileEnvironment() { + } + + public static boolean hasAnyActiveProfile(Environment environment, Set profiles) { + return Arrays.stream(environment.getActiveProfiles()) + .anyMatch(profile -> profiles.contains(profile.toLowerCase())); + } +} 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 01bbdd6..95758fb 100644 --- a/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java +++ b/src/main/java/gg/modl/backend/infrastructure/cors/DynamicCorsConfigurationSource.java @@ -1,9 +1,9 @@ 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.infrastructure.origin.OriginPolicyFactory; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.Server; import gg.modl.backend.infrastructure.util.HostExtractionUtil; @@ -24,7 +24,7 @@ public class DynamicCorsConfigurationSource implements CorsConfigurationSource { private final ServerService serverService; private final ModlCorsProperties corsProperties; - private final ModlProperties modlProperties; + private final OriginPolicyFactory originPolicyFactory; private final Map originCache = Collections.synchronizedMap( new LinkedHashMap<>(64, 0.75f, true) { @Override @@ -46,7 +46,7 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { } String path = request.getRequestURI(); - boolean adminPath = isAdminPath(path); + boolean adminPath = RouteGroups.isAdminArea(path); if (adminPath && !originPolicy.isSystemOrigin(origin)) { return null; @@ -71,18 +71,8 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { 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/") - ); + return RouteGroups.isReplayLiteChild(path) || RouteGroups.isPublicReplayLiteChild(path); } private boolean isOriginAllowed(String path, String origin) { @@ -121,11 +111,7 @@ private boolean checkOriginAllowed(String path, String origin) { @PostConstruct void initParsedOrigins() { - originPolicy = new OriginPolicy( - HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), - HostExtractionUtil.parseCommaSeparated(corsProperties.getAppDomains()), - modlProperties.isDevelopmentMode() - ); + originPolicy = originPolicyFactory.withAppDomains(); parsedReplayLiteOrigins = HostExtractionUtil.parseCommaSeparated(corsProperties.getReplayLiteOrigins()); } diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java b/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java index f028825..527f53c 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/CustomErrorController.java @@ -5,9 +5,7 @@ import gg.modl.proto.modl.v1.ApiError; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.http.HttpServletRequest; -import lombok.Getter; import lombok.RequiredArgsConstructor; -import lombok.Setter; import org.springframework.boot.webmvc.error.ErrorController; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -59,25 +57,6 @@ public ResponseEntity handleError(HttpServletRequest request) { .build()); } - ErrorResponse errorResponse = new ErrorResponse( - statusCode, - error, - errorMessage); - - return ResponseEntity.status(statusCode).body(errorResponse); - } - - @Setter - @Getter - public static class ErrorResponse { - private int status; - private String error; - private String message; - - public ErrorResponse(int status, String error, String message) { - this.status = status; - this.error = error; - this.message = message; - } + return ResponseEntity.status(statusCode).body(new ErrorResponseDTO(statusCode, error, errorMessage)); } } diff --git a/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java b/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java index 2409295..41332dc 100644 --- a/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java +++ b/src/main/java/gg/modl/backend/infrastructure/exception/GlobalExceptionHandler.java @@ -11,6 +11,7 @@ import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; @@ -31,18 +32,11 @@ @RestControllerAdvice @Slf4j +@RequiredArgsConstructor public class GlobalExceptionHandler { private static final String INVALID_DATA_MESSAGE = "Invalid data provided."; private final ProtobufErrorResponseWriter protobufErrorResponseWriter; - public GlobalExceptionHandler() { - this(new ProtobufErrorResponseWriter()); - } - - public GlobalExceptionHandler(ProtobufErrorResponseWriter protobufErrorResponseWriter) { - this.protobufErrorResponseWriter = protobufErrorResponseWriter; - } - @ExceptionHandler(SettingsConflictException.class) public ResponseEntity handleSettingsConflict(SettingsConflictException ex, HttpServletRequest request) { if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { @@ -58,31 +52,19 @@ public ResponseEntity handleSettingsConflict(SettingsConflictException ex, Ht @ExceptionHandler(BaseApplicationException.class) public ResponseEntity handleApplicationException(BaseApplicationException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(ex.getStatus(), HttpErrorMapping.machineCode(ex.getStatus()), ex.getMessage()); - } - return ResponseEntity.status(ex.getStatus()) - .body(new ErrorResponseDTO(ex.getStatus().value(), ex.getMessage())); + return respond(request, ex.getStatus(), HttpErrorMapping.machineCode(ex.getStatus()), ex.getMessage()); } @ExceptionHandler(DuplicateKeyException.class) public ResponseEntity handleDuplicateKey(DuplicateKeyException ex, HttpServletRequest request) { log.debug("Duplicate key violation for {} {}", request.getMethod(), request.getRequestURI()); - String message = "The request conflicts with an existing record."; - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.CONFLICT, "CONFLICT", message); - } - return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponseDTO(409, message)); + return respond(request, HttpStatus.CONFLICT, "CONFLICT", "The request conflicts with an existing record."); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity handleIllegalArgument(IllegalArgumentException ex, HttpServletRequest request) { log.warn("Unhandled IllegalArgumentException. Consider replacing with a typed exception at the throw site", ex); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Invalid argument"); - } - return ResponseEntity.badRequest() - .body(new ErrorResponseDTO(400, "Invalid argument")); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Invalid argument"); } @ExceptionHandler(MissingServletRequestParameterException.class) @@ -90,39 +72,22 @@ public ResponseEntity handleMissingParam( MissingServletRequestParameterException ex, HttpServletRequest request ) { - String message = "Missing required parameter: " + ex.getParameterName(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", message); - } - return ResponseEntity.badRequest() - .body(new ErrorResponseDTO(400, message)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Missing required parameter: " + ex.getParameterName()); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex, HttpServletRequest request) { - String message = "Invalid value for parameter: " + ex.getName(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", message); - } - return ResponseEntity.badRequest() - .body(new ErrorResponseDTO(400, message)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", "Invalid value for parameter: " + ex.getName()); } @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, INVALID_DATA_MESSAGE)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); } @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationException(MethodArgumentNotValidException ex, HttpServletRequest request) { - String message = fieldErrorMessage(ex); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", message); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, message)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", fieldErrorMessage(ex)); } private static String fieldErrorMessage(MethodArgumentNotValidException ex) { @@ -138,36 +103,22 @@ public ResponseEntity handleHandlerMethodValidation( HandlerMethodValidationException ex, HttpServletRequest request ) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, INVALID_DATA_MESSAGE)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleNotReadable(HttpMessageNotReadableException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); - } - return ResponseEntity.badRequest().body(new ErrorResponseDTO(400, INVALID_DATA_MESSAGE)); + return respond(request, HttpStatus.BAD_REQUEST, "INVALID_ARGUMENT", INVALID_DATA_MESSAGE); } @ExceptionHandler(HttpMediaTypeNotSupportedException.class) public ResponseEntity handleMediaTypeNotSupported(HttpMediaTypeNotSupportedException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type"); - } - return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) - .body(new ErrorResponseDTO(415, "Unsupported media type")); + return respond(request, HttpStatus.UNSUPPORTED_MEDIA_TYPE, "UNSUPPORTED_MEDIA_TYPE", "Unsupported media type"); } @ExceptionHandler(HttpMediaTypeNotAcceptableException.class) public ResponseEntity handleMediaTypeNotAcceptable(HttpMediaTypeNotAcceptableException ex, HttpServletRequest request) { - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.NOT_ACCEPTABLE, "NOT_ACCEPTABLE", "Not acceptable"); - } - return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE) - .body(new ErrorResponseDTO(406, "Not acceptable")); + return respond(request, HttpStatus.NOT_ACCEPTABLE, "NOT_ACCEPTABLE", "Not acceptable"); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) @@ -175,22 +126,12 @@ public ResponseEntity handleMethodNotSupported( HttpRequestMethodNotSupportedException ex, HttpServletRequest request ) { - String message = "HTTP method not supported: " + ex.getMethod(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", message); - } - return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) - .body(new ErrorResponseDTO(405, message)); + return respond(request, HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "HTTP method not supported: " + ex.getMethod()); } @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity handleNoHandlerFound(NoHandlerFoundException ex, HttpServletRequest request) { - String message = "No endpoint found for " + ex.getHttpMethod() + " " + ex.getRequestURL(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.NOT_FOUND, "NOT_FOUND", message); - } - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDTO(404, message)); + return respond(request, HttpStatus.NOT_FOUND, "NOT_FOUND", "No endpoint found for " + ex.getHttpMethod() + " " + ex.getRequestURL()); } @ExceptionHandler(NoResourceFoundException.class) @@ -198,12 +139,7 @@ public ResponseEntity handleNoResourceFound(NoResourceFoundException ex, Http if (log.isDebugEnabled()) { log.debug("No resource found for {} {}", request.getMethod(), ex.getResourcePath()); } - String message = "No endpoint found for " + request.getMethod() + " " + request.getRequestURI(); - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.NOT_FOUND, "NOT_FOUND", message); - } - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new ErrorResponseDTO(404, message)); + return respond(request, HttpStatus.NOT_FOUND, "NOT_FOUND", "No endpoint found for " + request.getMethod() + " " + request.getRequestURI()); } @ExceptionHandler(ProtoValidationException.class) @@ -238,18 +174,18 @@ public ResponseEntity handleGenericException(Exception ex, HttpServletRequest } String code = HttpErrorMapping.machineCode(status); String message = status.is4xxClientError() ? status.getReasonPhrase() : "An internal error occurred"; - if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(status, code, message); - } - return ResponseEntity.status(status).body(new ErrorResponseDTO(status.value(), message)); + return respond(request, status, code, message); } log.error("Unhandled exception", ex); + return respond(request, HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL", "An internal error occurred"); + } + + private ResponseEntity respond(HttpServletRequest request, HttpStatus status, String code, String message) { if (protobufErrorResponseWriter.shouldWriteProtobuf(request)) { - return protobufError(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL", "An internal error occurred"); + return protobufError(status, code, message); } - return ResponseEntity.internalServerError() - .body(new ErrorResponseDTO(500, "An internal error occurred")); + return ResponseEntity.status(status).body(new ErrorResponseDTO(status.value(), message)); } private ResponseEntity protobufError(HttpStatus status, String code, String message) { 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 5fe591f..9a884ad 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/AdminAuthFilter.java @@ -2,6 +2,7 @@ import gg.modl.backend.admin.service.AdminAuthService; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import gg.modl.backend.infrastructure.rest.RouteGroups; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -22,13 +23,21 @@ public class AdminAuthFilter extends OncePerRequestFilter { private final AdminAuthService adminAuthService; public static final String ADMIN_SESSION_ATTR = "adminSession"; + public static Optional actingEmail(HttpServletRequest request) { + Object attribute = request.getAttribute(ADMIN_SESSION_ATTR); + if (attribute instanceof AdminAuthService.AdminSession session) { + return Optional.ofNullable(session.email()); + } + return Optional.empty(); + } + @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/")) { + if (RouteGroups.isAdminChild(path) && !RouteGroups.isAdminAuthChild(path)) { Optional sessionOpt = adminAuthService.getAuthenticatedSession(request); if (sessionOpt.isEmpty()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java index 710a3ee..f372594 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/ApiKeyFilter.java @@ -1,12 +1,10 @@ package gg.modl.backend.infrastructure.filter; import gg.modl.backend.infrastructure.config.StagingEnvironment; -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 gg.modl.backend.infrastructure.rest.RequestAttribute; import gg.modl.backend.infrastructure.rest.RequestHeader; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.infrastructure.proto.ProtobufErrorResponseWriter; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.Server; @@ -35,10 +33,7 @@ public class ApiKeyFilter extends OncePerRequestFilter { protected boolean shouldNotFilter(HttpServletRequest request) { final String path = request.getRequestURI(); - return !path.startsWith(RESTMappingV1.PREFIX_MINECRAFT) - && !path.startsWith(RESTMappingV1.PREFIX_REPLAY_LITE) - && !path.startsWith(RESTMappingV2.PREFIX_MINECRAFT) - && !path.startsWith(RESTMappingV3.PREFIX_MINECRAFT); + return !RouteGroups.isMinecraftOrReplayLitePrefix(path); } @Override 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 62096c6..3472b16 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/FilterConfig.java @@ -1,8 +1,8 @@ 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.origin.OriginPolicyFactory; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.server.ServerService; import lombok.RequiredArgsConstructor; @@ -18,7 +18,7 @@ public class FilterConfig { private final ApiKeyFilter apiKeyFilter; private final ModlProperties modlProperties; private final ModlDevProperties devProperties; - private final ModlCorsProperties corsProperties; + private final OriginPolicyFactory originPolicyFactory; @Bean public FilterRegistrationBean serverDomainFilter() { @@ -27,7 +27,7 @@ public FilterRegistrationBean serverDomainFilter() { serverService, modlProperties.isDevelopmentMode(), devProperties.getServerDomain(), - corsProperties.getSystemOrigins() + originPolicyFactory.systemOriginsOnly() )); registrationBean.addUrlPatterns(RESTMappingV1.PREFIX_PANEL + "/*", RESTMappingV1.PREFIX_PUBLIC + "/*"); registrationBean.setOrder(Ordered.HIGHEST_PRECEDENCE + 2); diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java index f2ad6b6..36b2980 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/OriginCsrfFilter.java @@ -1,11 +1,10 @@ package gg.modl.backend.infrastructure.filter; import gg.modl.backend.auth.AuthConfiguration; -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.infrastructure.origin.OriginPolicyFactory; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.infrastructure.rest.RequestAttribute; import gg.modl.backend.infrastructure.rest.RequestHeader; import gg.modl.backend.infrastructure.util.HostExtractionUtil; @@ -28,8 +27,7 @@ @RequiredArgsConstructor public class OriginCsrfFilter extends OncePerRequestFilter { private final AuthConfiguration authConfiguration; - private final ModlCorsProperties corsProperties; - private final ModlProperties modlProperties; + private final OriginPolicyFactory originPolicyFactory; private volatile OriginPolicy originPolicy = new OriginPolicy(Set.of(), Set.of(), false); @Override @@ -40,10 +38,7 @@ protected boolean shouldNotFilter(HttpServletRequest request) { } final String path = request.getRequestURI(); - final boolean panelOrAdmin = path.startsWith(RESTMappingV1.PREFIX_PANEL + "/") - || path.equals(RESTMappingV1.PREFIX_PANEL) - || path.startsWith(RESTMappingV1.PREFIX_ADMIN + "/") - || path.equals(RESTMappingV1.PREFIX_ADMIN); + final boolean panelOrAdmin = RouteGroups.isPanelArea(path) || RouteGroups.isAdminArea(path); return !panelOrAdmin || !hasSessionCookie(request); } @@ -94,11 +89,7 @@ private boolean hasSessionCookie(HttpServletRequest request) { @PostConstruct void initParsedOrigins() { - originPolicy = new OriginPolicy( - HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), - Set.of(), - modlProperties.isDevelopmentMode() - ); + originPolicy = originPolicyFactory.systemOriginsOnly(); } private void reject(HttpServletResponse response) throws IOException { @@ -144,8 +135,7 @@ private boolean isAllowedReferer(HttpServletRequest request, String referer) { } private boolean isPanelPath(String path) { - return path != null && (path.startsWith(RESTMappingV1.PREFIX_PANEL + "/") - || path.equals(RESTMappingV1.PREFIX_PANEL)); + return RouteGroups.isPanelArea(path); } private String resolveRequestServerDomain(HttpServletRequest request) { 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 31d5122..0bd8b65 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/PanelPermissionFilter.java @@ -1,13 +1,17 @@ package gg.modl.backend.infrastructure.filter; -import gg.modl.backend.infrastructure.rest.RESTMappingV1; +import gg.modl.backend.infrastructure.authorization.PanelAccessPolicy; +import gg.modl.backend.infrastructure.authorization.PanelAccessPolicyResolver; +import gg.modl.backend.infrastructure.authorization.PanelAccessRequest; +import gg.modl.backend.infrastructure.authorization.PanelPrincipalPermissions; import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.infrastructure.rest.RouteGroups; 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 gg.modl.backend.staff.service.StaffLookupCache; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -15,7 +19,6 @@ 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; @@ -25,42 +28,16 @@ @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" - ); + private final StaffLookupCache staffLookupCache; + private final PanelAccessPolicyResolver policyResolver; @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getRequestURI(); - if (!path.startsWith(RESTMappingV1.PREFIX_PANEL)) { + if (!RouteGroups.isPanelPrefix(path)) { return true; } - return startsWithEndpoint(path, RESTMappingV1.PANEL_AUTH); + return RouteGroups.isPanelAuthArea(path); } @Override @@ -76,70 +53,33 @@ protected void doFilterInternal( 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) { + List policies = policyResolver.resolvePolicies(request); + if (policies.isEmpty()) { deny(response); return; } - if (PERMIT.equals(requiredPermission)) { + PanelAccessRequest accessRequest = new PanelAccessRequest(request.getMethod(), request.getRequestURI()); + if (policies.stream().anyMatch(policy -> policy.permitsWithoutRole(accessRequest))) { filterChain.doFilter(request, response); return; } - Optional staffOpt = staffService.getStaffByEmail(server, email); + Optional staffOpt = staffLookupCache.findByEmail(server, email); String roleId = staffOpt.map(staff -> RoleAuthorization.effectiveRoleId(server, staff)).orElse(null); + PanelPrincipalPermissions permissions = new PanelPrincipalPermissions(server, roleId, permissionService); - 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); + if (policies.stream().anyMatch(policy -> policy.permitsWithRole(accessRequest, permissions))) { + filterChain.doFilter(request, 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"); + deny(response); } private void deny(HttpServletResponse response) throws IOException { @@ -147,87 +87,4 @@ private void deny(HttpServletResponse response) throws IOException { 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/ServerHeaderFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java index ddcf088..77fc44f 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/ServerHeaderFilter.java @@ -30,19 +30,19 @@ public class ServerHeaderFilter extends OncePerRequestFilter { ); public ServerHeaderFilter(ServerService serverService) { - this(serverService, false, null, null); + this(serverService, false, null, new OriginPolicy(Set.of(), Set.of(), false)); } public ServerHeaderFilter( ServerService serverService, boolean developmentMode, @Nullable String devServerDomain, - @Nullable String systemOrigins + OriginPolicy originPolicy ) { this.serverService = serverService; this.developmentMode = developmentMode; this.devServerDomain = devServerDomain; - this.originPolicy = new OriginPolicy(HostExtractionUtil.parseCommaSeparated(systemOrigins), Set.of(), developmentMode); + this.originPolicy = originPolicy; } @Override diff --git a/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java b/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java index 0b7dbd4..4faba7a 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/SessionAuthenticationFilter.java @@ -3,11 +3,9 @@ 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.rest.RESTMappingV1; -import gg.modl.backend.infrastructure.rest.RESTMappingV2; -import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RESTSecurityRole; import gg.modl.backend.infrastructure.rest.RequestAttribute; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.backend.infrastructure.util.CookieUtil; import gg.modl.backend.server.data.Server; import jakarta.servlet.FilterChain; @@ -52,9 +50,7 @@ protected boolean shouldNotFilter(@NotNull HttpServletRequest request) { return true; } - if (path.startsWith(RESTMappingV1.PREFIX_MINECRAFT) - || path.startsWith(RESTMappingV2.PREFIX_MINECRAFT) - || path.startsWith(RESTMappingV3.PREFIX_MINECRAFT)) { + if (RouteGroups.isMinecraftApiPrefix(path)) { return true; } 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 7ce16e2..93c1920 100644 --- a/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/filter/V1SecurityConfig.java @@ -1,7 +1,5 @@ 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; diff --git a/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java b/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java index 92c20bf..76672f1 100644 --- a/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java +++ b/src/main/java/gg/modl/backend/infrastructure/onetimecode/OneTimeCodeCodec.java @@ -1,7 +1,9 @@ package gg.modl.backend.infrastructure.onetimecode; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.util.Base64; import javax.crypto.Mac; @@ -32,7 +34,7 @@ public String hash(String code, String secret) { } MessageDigest digest = MessageDigest.getInstance(DIGEST_ALGORITHM); return Base64.getEncoder().encodeToString(digest.digest(input)); - } catch (Exception e) { + } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new IllegalStateException("Failed to hash one-time code", e); } } diff --git a/src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java b/src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java new file mode 100644 index 0000000..b88af11 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/origin/OriginPolicyFactory.java @@ -0,0 +1,31 @@ +package gg.modl.backend.infrastructure.origin; + +import gg.modl.backend.infrastructure.config.ModlCorsProperties; +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.infrastructure.util.HostExtractionUtil; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class OriginPolicyFactory { + private final ModlCorsProperties corsProperties; + private final ModlProperties modlProperties; + + public OriginPolicy withAppDomains() { + return new OriginPolicy( + HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), + HostExtractionUtil.parseCommaSeparated(corsProperties.getAppDomains()), + modlProperties.isDevelopmentMode() + ); + } + + public OriginPolicy systemOriginsOnly() { + return new OriginPolicy( + HostExtractionUtil.parseCommaSeparated(corsProperties.getSystemOrigins()), + Set.of(), + modlProperties.isDevelopmentMode() + ); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java index bb26276..1d5a6d0 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoBinaryHttpMessageConverter.java @@ -28,7 +28,7 @@ protected boolean supports(@NonNull Class clazz) { protected Message readInternal(@NonNull Class clazz, @NonNull HttpInputMessage inputMessage) throws IOException { try { - Message defaultInstance = getDefaultInstance(clazz); + Message defaultInstance = ProtoMessages.defaultInstance(clazz); return defaultInstance.getParserForType().parseFrom(inputMessage.getBody()); } catch (Exception e) { throw new HttpMessageNotReadableException("Failed to parse binary protobuf: " + e.getMessage(), e, inputMessage); @@ -44,12 +44,4 @@ protected void writeInternal(@NonNull Message message, throw new HttpMessageNotWritableException("Failed to write binary protobuf: " + e.getMessage(), e); } } - - private static Message getDefaultInstance(Class clazz) { - try { - return (Message) clazz.getMethod("getDefaultInstance").invoke(null); - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException("Cannot get default instance for " + clazz.getName(), e); - } - } } diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java index ba2d47f..d3098fe 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoJsonHttpMessageConverter.java @@ -38,7 +38,7 @@ protected boolean supports(@NonNull Class clazz) { protected Message readInternal(@NonNull Class clazz, @NonNull HttpInputMessage inputMessage) throws IOException { try { - Message.Builder builder = getDefaultInstance(clazz).newBuilderForType(); + Message.Builder builder = ProtoMessages.defaultInstance(clazz).newBuilderForType(); try (InputStreamReader reader = new InputStreamReader(inputMessage.getBody(), StandardCharsets.UTF_8)) { PARSER.merge(reader, builder); } @@ -57,12 +57,4 @@ protected void writeInternal(@NonNull Message message, throw new HttpMessageNotWritableException("Failed to write proto JSON: " + e.getMessage(), e); } } - - private static Message getDefaultInstance(Class clazz) { - try { - return (Message) clazz.getMethod("getDefaultInstance").invoke(null); - } catch (ReflectiveOperationException e) { - throw new IllegalArgumentException("Cannot get default instance for " + clazz.getName(), e); - } - } } diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java index bf4088e..f0a3de8 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMapperSupport.java @@ -48,6 +48,14 @@ public static String stringValue(Object value) { return value == null ? "" : Objects.toString(value); } + public static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public static String emptyToNull(String value) { + return value == null || value.isEmpty() ? null : value; + } + public static String dateAwareString(Object value) { if (value instanceof Date date) { return date.toInstant().toString(); @@ -133,9 +141,21 @@ public static void setOptionalBoolean(Consumer setter, Object value) { } } - public static void setOptionalDouble(Consumer setter, Object value) { + public static void setOptionalEpochMillis(Consumer setter, Date value) { + if (value != null) { + setter.accept(value.getTime()); + } + } + + public static void setOptionalTimestamp(Consumer setter, Date value) { if (value != null) { - setter.accept(doubleValue(value)); + setter.accept(toTimestamp(value)); + } + } + + public static void setOptionalStrings(Consumer> setter, Collection values) { + if (values != null) { + setter.accept(values); } } @@ -161,6 +181,24 @@ public static Map structToMap(Struct struct) { return result; } + public static List structListToObjects(List structs) { + if (structs.isEmpty()) { + return null; + } + return structs.stream() + .map(struct -> (Object) structToMap(struct)) + .toList(); + } + + public static List> structListToMaps(List structs) { + if (structs.isEmpty()) { + return null; + } + return structs.stream() + .map(ProtoMapperSupport::structToMap) + .toList(); + } + public static Timestamp toTimestamp(Object value) { long millis = longValue(value); return Timestamp.newBuilder() @@ -183,6 +221,13 @@ public static List> listOfMaps(Object object) { .toList(); } + public static List listOf(Object object, Class elementType) { + return list(object).stream() + .filter(elementType::isInstance) + .map(elementType::cast) + .toList(); + } + public static Map map(Object object) { if (object instanceof Map rawMap) { return stringObjectMap(rawMap); @@ -196,6 +241,14 @@ public static Map stringObjectMap(Map rawMap) { return result; } + @SuppressWarnings("unchecked") + public static Map asMap(Object value) { + if (value instanceof Map map) { + return (Map) map; + } + return Map.of(); + } + public static void addAll(Collection source, Function converter, Consumer adder) { @@ -295,7 +348,16 @@ private static Value integralValue(Value.Builder builder, BigInteger value) { : builder.setNumberValue(value.doubleValue()).build(); } - private static Object valueToObject(Value value) { + public static List valuesToObjects(List values) { + if (values.isEmpty()) { + return null; + } + return values.stream() + .map(ProtoMapperSupport::valueToObject) + .toList(); + } + + public static Object valueToObject(Value value) { return switch (value.getKindCase()) { case NULL_VALUE, KIND_NOT_SET -> null; case NUMBER_VALUE -> value.getNumberValue(); diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java index c5fce18..76d47bb 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessageConverterConfig.java @@ -1,27 +1,20 @@ package gg.modl.backend.infrastructure.proto; -import java.util.List; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; -import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverters; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration +@RequiredArgsConstructor public class ProtoMessageConverterConfig implements WebMvcConfigurer { private final ProtoJsonHttpMessageConverter protoJsonConverter; private final ProtoBinaryHttpMessageConverter protoBinaryConverter; - public ProtoMessageConverterConfig(ProtoJsonHttpMessageConverter protoJsonConverter, - ProtoBinaryHttpMessageConverter protoBinaryConverter) { - this.protoJsonConverter = protoJsonConverter; - this.protoBinaryConverter = protoBinaryConverter; - } - @Override - public void extendMessageConverters(List> converters) { - converters.removeIf(c -> c instanceof ProtoJsonHttpMessageConverter - || c instanceof ProtoBinaryHttpMessageConverter); - converters.add(0, protoBinaryConverter); - converters.add(0, protoJsonConverter); + public void configureMessageConverters(HttpMessageConverters.ServerBuilder builder) { + builder.addCustomConverter(protoJsonConverter); + builder.addCustomConverter(protoBinaryConverter); } } diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java new file mode 100644 index 0000000..0db049c --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoMessages.java @@ -0,0 +1,16 @@ +package gg.modl.backend.infrastructure.proto; + +import com.google.protobuf.Message; + +public final class ProtoMessages { + private ProtoMessages() { + } + + public static Message defaultInstance(Class clazz) { + try { + return (Message) clazz.getMethod("getDefaultInstance").invoke(null); + } catch (ReflectiveOperationException e) { + throw new IllegalArgumentException("Cannot get default instance for " + clazz.getName(), e); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java index 4a7f58f..17a7fb7 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtoValidationException.java @@ -15,7 +15,7 @@ public class ProtoValidationException extends RuntimeException { public ProtoValidationException(ValidationResult result) { super(formatViolations(result.getViolations())); - this.violations = result.getViolations(); + this.violations = List.copyOf(result.getViolations()); } public List getViolations() { diff --git a/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java b/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java index 5465ff2..a0137cb 100644 --- a/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java +++ b/src/main/java/gg/modl/backend/infrastructure/proto/ProtobufErrorResponseWriter.java @@ -1,7 +1,6 @@ package gg.modl.backend.infrastructure.proto; -import gg.modl.backend.infrastructure.rest.RESTMappingV2; -import gg.modl.backend.infrastructure.rest.RESTMappingV3; +import gg.modl.backend.infrastructure.rest.RouteGroups; import gg.modl.proto.modl.v1.ApiError; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.http.HttpServletRequest; @@ -20,10 +19,10 @@ public class ProtobufErrorResponseWriter { public boolean shouldWriteProtobuf(HttpServletRequest request) { String path = protobufDecisionPath(request); - if (path != null && path.startsWith(RESTMappingV3.PREFIX)) { + if (RouteGroups.isVersion3Prefix(path)) { return true; } - if (path != null && (path.startsWith("/v1") || path.startsWith(RESTMappingV2.PREFIX_MINECRAFT))) { + if (RouteGroups.isVersion1Prefix(path) || RouteGroups.isVersion2MinecraftPrefix(path)) { return false; } return acceptsProtobuf(Collections.list(request.getHeaders(HttpHeaders.ACCEPT))); diff --git a/src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java b/src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java new file mode 100644 index 0000000..b2fdd07 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/ratelimit/BucketPool.java @@ -0,0 +1,34 @@ +package gg.modl.backend.infrastructure.ratelimit; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +@Component +public class BucketPool { + + private static final int MAX_BUCKETS_PER_NAMESPACE = 50_000; + + private final Map> namespaces = new ConcurrentHashMap<>(); + + public Bucket resolveBucket(String namespace, String key, int capacity, Duration refillDuration) { + return namespaces + .computeIfAbsent(namespace, ignored -> Caffeine.newBuilder() + .maximumSize(MAX_BUCKETS_PER_NAMESPACE) + .build()) + .get(key, ignored -> createBucket(capacity, refillDuration)); + } + + private Bucket createBucket(int capacity, Duration refillDuration) { + Bandwidth limit = Bandwidth.builder() + .capacity(capacity) + .refillGreedy(capacity, refillDuration) + .build(); + return Bucket.builder().addLimit(limit).build(); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java b/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java index 37f88c5..5fb6116 100644 --- a/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/ratelimit/RateLimitConfig.java @@ -1,21 +1,23 @@ package gg.modl.backend.infrastructure.ratelimit; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; -import io.github.bucket4j.Bandwidth; +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.RouteGroups; import io.github.bucket4j.Bucket; import java.time.Duration; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class RateLimitConfig { - private final Map> buckets = new ConcurrentHashMap<>(); - private static final int MAX_BUCKETS_PER_TIER = 50_000; + private final BucketPool bucketPool; + private static final Set HEAVY_PANEL_WRITE_PATTERNS = Set.of( "/staff/invite", "/settings", "/find-linked" ); @@ -29,34 +31,22 @@ public class RateLimitConfig { ); private static final List PREFIX_RULES = List.of( new PathRule("/v1/webhooks/", RateLimitTier.WEBHOOK), - new PathRule("/v1/replay-lite/", RateLimitTier.REPLAY_LITE_UPLOAD), - new PathRule("/v1/minecraft/", RateLimitTier.MINECRAFT_STANDARD), - new PathRule("/v2/minecraft/", RateLimitTier.MINECRAFT_STANDARD), - new PathRule("/v3/minecraft/", RateLimitTier.MINECRAFT_STANDARD), - new PathRule("/v1/admin/auth/session", RateLimitTier.ADMIN_SESSION), - new PathRule("/v1/admin/auth/", RateLimitTier.ADMIN_AUTH), - new PathRule("/v1/admin/beta-testers", RateLimitTier.ADMIN_BETA), - new PathRule("/v1/admin/", RateLimitTier.ADMIN_STANDARD) + new PathRule(RESTMappingV1.PREFIX_REPLAY_LITE + "/", RateLimitTier.REPLAY_LITE_UPLOAD), + new PathRule(RESTMappingV1.PREFIX_MINECRAFT + "/", RateLimitTier.MINECRAFT_STANDARD), + new PathRule(RESTMappingV2.PREFIX_MINECRAFT + "/", RateLimitTier.MINECRAFT_STANDARD), + new PathRule(RESTMappingV3.PREFIX_MINECRAFT + "/", RateLimitTier.MINECRAFT_STANDARD), + new PathRule(RESTMappingV1.ADMIN_AUTH + "/session", RateLimitTier.ADMIN_SESSION), + new PathRule(RESTMappingV1.ADMIN_AUTH + "/", RateLimitTier.ADMIN_AUTH), + new PathRule(RESTMappingV1.ADMIN_BETA_TESTERS, RateLimitTier.ADMIN_BETA), + new PathRule(RESTMappingV1.PREFIX_ADMIN + "/", RateLimitTier.ADMIN_STANDARD) ); private static final Map EXACT_PATH_RULES = Map.of( - "/v1/minecraft/players/login", RateLimitTier.MINECRAFT_LOGIN, - "/v3/minecraft/players/login", RateLimitTier.MINECRAFT_LOGIN + RESTMappingV1.MINECRAFT_PLAYERS + "/login", RateLimitTier.MINECRAFT_LOGIN, + RESTMappingV3.PREFIX_MINECRAFT + "/players/login", RateLimitTier.MINECRAFT_LOGIN ); public Bucket resolveBucket(String clientKey, RateLimitTier tier) { - return buckets - .computeIfAbsent(tier, t -> Caffeine.newBuilder() - .maximumSize(MAX_BUCKETS_PER_TIER) - .build()) - .get(clientKey, k -> createBucket(tier)); - } - - private Bucket createBucket(RateLimitTier tier) { - Bandwidth limit = Bandwidth.builder() - .capacity(tier.getCapacity()) - .refillGreedy(tier.getCapacity(), tier.getRefillDuration()) - .build(); - return Bucket.builder().addLimit(limit).build(); + return bucketPool.resolveBucket(tier.name(), clientKey, tier.getCapacity(), tier.getRefillDuration()); } public RateLimitTier getTierForPath(String path, String method) { @@ -75,22 +65,22 @@ public RateLimitTier getTierForPath(String path, String method) { } } - if (path.startsWith("/v1/panel/auth/")) { - boolean sendCode = path.equals("/v1/panel/auth/send-email-code") - || path.equals("/v1/panel/auth/email/send-code"); + if (path.startsWith(RESTMappingV1.PANEL_AUTH + "/")) { + boolean sendCode = path.equals(RESTMappingV1.PANEL_AUTH + "/send-email-code") + || path.equals(RESTMappingV1.PANEL_AUTH + "/email/send-code"); return sendCode ? RateLimitTier.AUTH_SEND_CODE : RateLimitTier.AUTH; } - if (path.startsWith("/v1/panel/migration/")) { - return path.equals("/v1/panel/migration/status") && "GET".equalsIgnoreCase(method) + if (path.startsWith(RESTMappingV1.PANEL_MIGRATION + "/")) { + return path.equals(RESTMappingV1.PANEL_MIGRATION + "/status") && "GET".equalsIgnoreCase(method) ? RateLimitTier.MIGRATION_STATUS : RateLimitTier.MIGRATION; } - if (path.startsWith("/v1/panel/")) { + if (RouteGroups.isPanelChild(path)) { return resolvePanelTier(path, method); } - if (path.startsWith("/v1/public/")) { + if (RouteGroups.isPublicChild(path)) { return resolvePublicTier(path, method); } @@ -108,21 +98,21 @@ private RateLimitTier resolvePanelTier(String path, String method) { } private RateLimitTier resolvePublicTier(String path, String method) { - if (path.startsWith("/v1/public/replay-lite/") && isWriteMethod(method)) { + if (RouteGroups.isPublicReplayLiteChild(path) && isWriteMethod(method)) { return RateLimitTier.REPLAY_LITE_LABEL; } - if (path.startsWith("/v1/public/media/") && isWriteMethod(method)) { + if (path.startsWith(RESTMappingV1.PUBLIC_MEDIA + "/") && isWriteMethod(method)) { return RateLimitTier.PUBLIC_MEDIA_UPLOAD; } - if (path.startsWith("/v1/public/appeals") && isWriteMethod(method) + if (path.startsWith(RESTMappingV1.PUBLIC_APPEALS) && isWriteMethod(method) && (path.contains("/verify") || path.contains("/request-verification"))) { return RateLimitTier.PUBLIC_TICKET_VERIFY; } - if (path.startsWith("/v1/public/tickets") && isWriteMethod(method)) { + if (path.startsWith(RESTMappingV1.PUBLIC_TICKETS) && isWriteMethod(method)) { if (path.contains("/verify") || path.contains("/request-verification")) { return RateLimitTier.PUBLIC_TICKET_VERIFY; } - if (path.equals("/v1/public/tickets") || path.equals("/v1/public/tickets/unfinished")) { + if (path.equals(RESTMappingV1.PUBLIC_TICKETS) || path.equals(RESTMappingV1.PUBLIC_TICKETS + "/unfinished")) { return RateLimitTier.PUBLIC_TICKET_CREATE; } return RateLimitTier.PUBLIC_TICKET_INTERACT; diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java b/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java index 45c38cf..bf977fe 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/MetricsConfig.java @@ -1,6 +1,5 @@ package gg.modl.backend.infrastructure.rest; -import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.config.MeterFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -8,12 +7,6 @@ @Configuration public class MetricsConfig { - /** - * Limit the cardinality of the 'uri' tag on HTTP server metrics. - * Without this, dynamic path segments (e.g. /v1/public/players/{uuid}) - * create unbounded unique tag values, triggering - * MaximumAllowableTagsMeterFilter warnings and bloating Prometheus. - */ @Bean public MeterFilter uriTagCardinalityFilter() { return MeterFilter.maximumAllowableTags("http.server.requests", "uri", 200, MeterFilter.deny()); diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java index 73250ae..9b21a71 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV1.java @@ -1,48 +1,39 @@ package gg.modl.backend.infrastructure.rest; public final class RESTMappingV1 { - private static final String V1 = "/v1"; + public static final String V1 = "/v1"; public static final String PREFIX_PANEL = V1 + "/panel"; public static final String PREFIX_ADMIN = V1 + "/admin"; - // Admin routes public static final String ADMIN_AUTH = PREFIX_ADMIN + "/auth"; public static final String PREFIX_PUBLIC = V1 + "/public"; public static final String PREFIX_MINECRAFT = V1 + "/minecraft"; public static final String PREFIX_REPLAY_LITE = V1 + "/replay-lite"; - // Health check + public static final String PUBLIC_REPLAY_LITE = PREFIX_PUBLIC + "/replay-lite"; public static final String HEALTH = V1 + "/health"; - // Server private static final String SERVER = "/server"; public static final String PUBLIC_SERVER = PREFIX_PUBLIC + SERVER; public static final String PANEL_SERVER = PREFIX_PANEL + SERVER; - // Player private static final String PLAYERS = "/players"; public static final String PANEL_PLAYERS = PREFIX_PANEL + PLAYERS; public static final String PUBLIC_PLAYERS = PREFIX_PUBLIC + PLAYERS; public static final String MINECRAFT_PLAYERS = PREFIX_MINECRAFT + PLAYERS; - // Auth private static final String AUTH = "/auth"; public static final String PANEL_AUTH = PREFIX_PANEL + AUTH; - // Settings private static final String SETTINGS = "/settings"; public static final String PANEL_SETTINGS = PREFIX_PANEL + SETTINGS; - // Public settings and media public static final String PUBLIC_SETTINGS = PREFIX_PUBLIC + SETTINGS; - // Tickets private static final String TICKETS = "/tickets"; public static final String PANEL_TICKETS = PREFIX_PANEL + TICKETS; public static final String PUBLIC_TICKETS = PREFIX_PUBLIC + TICKETS; public static final String MINECRAFT_TICKETS = PREFIX_MINECRAFT + TICKETS; private static final String TICKET_SUBSCRIPTIONS = "/ticket-subscriptions"; public static final String PANEL_TICKET_SUBSCRIPTIONS = PREFIX_PANEL + TICKET_SUBSCRIPTIONS; - // Staff & Roles private static final String STAFF = "/staff"; public static final String PANEL_STAFF = PREFIX_PANEL + STAFF; public static final String MINECRAFT_STAFF = PREFIX_MINECRAFT + STAFF; private static final String ROLES = "/roles"; public static final String PANEL_ROLES = PREFIX_PANEL + ROLES; public static final String MINECRAFT_ROLES = PREFIX_MINECRAFT + ROLES; - // Analytics, Audit & Dashboard private static final String ANALYTICS = "/analytics"; public static final String PANEL_ANALYTICS = PREFIX_PANEL + ANALYTICS; public static final String ADMIN_ANALYTICS = PREFIX_ADMIN + ANALYTICS; @@ -51,7 +42,6 @@ public final class RESTMappingV1 { private static final String DASHBOARD = "/dashboard"; public static final String PANEL_DASHBOARD = PREFIX_PANEL + DASHBOARD; public static final String MINECRAFT_DASHBOARD = PREFIX_MINECRAFT + DASHBOARD; - // Billing & Storage private static final String BILLING = "/billing"; public static final String PANEL_BILLING = PREFIX_PANEL + BILLING; private static final String MEDIA = "/media"; @@ -59,41 +49,32 @@ public final class RESTMappingV1 { public static final String PUBLIC_MEDIA = PREFIX_PUBLIC + MEDIA; private static final String STORAGE = "/storage"; public static final String PANEL_STORAGE = PREFIX_PANEL + STORAGE; - // Knowledgebase & Homepage private static final String KNOWLEDGEBASE = "/knowledgebase"; public static final String PANEL_KNOWLEDGEBASE = PREFIX_PANEL + KNOWLEDGEBASE; public static final String PUBLIC_KNOWLEDGEBASE = PREFIX_PUBLIC + KNOWLEDGEBASE; private static final String HOMEPAGE_CARDS = "/homepage-cards"; public static final String PANEL_HOMEPAGE_CARDS = PREFIX_PANEL + HOMEPAGE_CARDS; public static final String PUBLIC_HOMEPAGE_CARDS = PREFIX_PUBLIC + HOMEPAGE_CARDS; - // Webhooks private static final String WEBHOOKS = "/webhooks"; public static final String WEBHOOKS_STRIPE = V1 + WEBHOOKS + "/stripe"; - // Appeals private static final String APPEALS = "/appeals"; public static final String PANEL_APPEALS = PREFIX_PANEL + APPEALS; public static final String PUBLIC_APPEALS = PREFIX_PUBLIC + APPEALS; - // Registration private static final String REGISTRATION = "/registration"; public static final String PUBLIC_REGISTRATION = PREFIX_PUBLIC + REGISTRATION; - // Logs private static final String LOGS = "/logs"; public static final String PANEL_LOGS = PREFIX_PANEL + LOGS; - // Migration private static final String MIGRATION = "/migration"; public static final String PANEL_MIGRATION = PREFIX_PANEL + MIGRATION; public static final String MINECRAFT_MIGRATION = PREFIX_MINECRAFT + MIGRATION; - // Minecraft API endpoints private static final String PUNISHMENTS = "/punishments"; public static final String MINECRAFT_PUNISHMENTS = PREFIX_MINECRAFT + PUNISHMENTS; - // Public punishment endpoint private static final String PUNISHMENT = "/punishment"; public static final String PUBLIC_PUNISHMENT = PREFIX_PUBLIC + PUNISHMENT; private static final String NOTIFICATIONS = "/notifications"; public static final String MINECRAFT_NOTIFICATIONS = PREFIX_MINECRAFT + NOTIFICATIONS; private static final String REPORTS = "/reports"; public static final String MINECRAFT_REPORTS = PREFIX_MINECRAFT + REPORTS; - // Evidence upload (public, token-based auth) private static final String EVIDENCE_UPLOAD = "/evidence-upload"; public static final String PUBLIC_EVIDENCE_UPLOAD = PREFIX_PUBLIC + EVIDENCE_UPLOAD; private static final String SERVERS = "/servers"; @@ -115,11 +96,13 @@ public final class RESTMappingV1 { private static final String REALTIME = "/realtime"; public static final String REALTIME_WS = V1 + REALTIME + "/ws"; - // Replays private static final String REPLAYS = "/replays"; public static final String MINECRAFT_REPLAYS = PREFIX_MINECRAFT + REPLAYS; public static final String PUBLIC_REPLAYS = PREFIX_PUBLIC + REPLAYS; public static final String PANEL_REPLAYS = PREFIX_PANEL + REPLAYS; public static final String REPLAY_LITE_REPLAYS = PREFIX_REPLAY_LITE + REPLAYS; - public static final String PUBLIC_REPLAY_LITE_REPLAYS = PREFIX_PUBLIC + "/replay-lite" + REPLAYS; + public static final String PUBLIC_REPLAY_LITE_REPLAYS = PUBLIC_REPLAY_LITE + REPLAYS; + + private RESTMappingV1() { + } } diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java index b165de4..8ea18f8 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTMappingV2.java @@ -5,4 +5,7 @@ public final class RESTMappingV2 { public static final String PREFIX_MINECRAFT = V2 + "/minecraft"; private static final String PLAYERS = "/players"; public static final String MINECRAFT_PLAYERS = PREFIX_MINECRAFT + PLAYERS; + + private RESTMappingV2() { + } } 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 ad5d4bf..b155165 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RESTSecurityRole.java @@ -5,4 +5,7 @@ public final class RESTSecurityRole { public static final String ADMIN = "ADMIN"; public static final String MINECRAFT = "MINECRAFT"; public static final String ADMIN_SESSION_COOKIE = "modl.admin.session"; + + private RESTSecurityRole() { + } } 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 e8f194b..eb2e4f3 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestAttribute.java @@ -4,4 +4,7 @@ public final class RequestAttribute { public static final String SERVER = "gg.modl.attribute.server"; public static final String SERVER_DOMAIN = "gg.modl.attribute.serverDomain"; public static final String SESSION = "gg.modl.attribute.session"; + + private RequestAttribute() { + } } 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 c20ee88..fd58840 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestHeader.java @@ -4,4 +4,7 @@ public final class RequestHeader { public static final String SERVER_DOMAIN = "X-Server-Domain"; public static final String FORWARDED_HOST = "X-Forwarded-Host"; public static final String API_KEY = "X-API-Key"; + + private RequestHeader() { + } } 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 cb61d3c..eb492ff 100644 --- a/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RequestUtil.java @@ -9,11 +9,12 @@ import java.util.Base64; import java.util.Objects; import java.util.regex.Pattern; +import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +@Slf4j 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(); @@ -21,25 +22,26 @@ public final class RequestUtil { 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"); + private RequestUtil() { + } + + private static String resolveConfig(String propertyKey, String envKey) { + String value = System.getProperty(propertyKey); if (value == null) { - value = System.getProperty("MODL_TRUST_PROXY_HEADERS"); + value = System.getProperty(envKey); } if (value == null) { - value = System.getenv("MODL_TRUST_PROXY_HEADERS"); + value = System.getenv(envKey); } - return Boolean.parseBoolean(value); + return value; + } + + private static boolean resolveTrustProxyHeaders() { + return Boolean.parseBoolean(resolveConfig("modl.trust-proxy-headers", "MODL_TRUST_PROXY_HEADERS")); } 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"); - } + String value = resolveConfig("modl.client-ip-header", "MODL_CLIENT_IP_HEADER"); if (value == null || value.isBlank()) { return "CF-Connecting-IP"; } @@ -47,13 +49,7 @@ private static String resolveClientIpHeaderName() { } 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"); - } + String value = resolveConfig("modl.trusted-proxy-count", "MODL_TRUSTED_PROXY_COUNT"); if (value == null || value.isBlank()) { return 1; } @@ -96,7 +92,6 @@ public static String getCurrentUsername(HttpServletRequest 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(); } diff --git a/src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java b/src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java new file mode 100644 index 0000000..0ce84b8 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/rest/RouteGroups.java @@ -0,0 +1,81 @@ +package gg.modl.backend.infrastructure.rest; + +public final class RouteGroups { + + private RouteGroups() { + } + + public static boolean isPanelArea(String path) { + return matchesExactOrChild(path, RESTMappingV1.PREFIX_PANEL); + } + + public static boolean isAdminArea(String path) { + return matchesExactOrChild(path, RESTMappingV1.PREFIX_ADMIN); + } + + public static boolean isPanelAuthArea(String path) { + return matchesExactOrChild(path, RESTMappingV1.PANEL_AUTH); + } + + public static boolean isPanelPrefix(String path) { + return matchesPrefix(path, RESTMappingV1.PREFIX_PANEL); + } + + public static boolean isMinecraftApiPrefix(String path) { + return matchesPrefix(path, RESTMappingV1.PREFIX_MINECRAFT) + || matchesPrefix(path, RESTMappingV2.PREFIX_MINECRAFT) + || matchesPrefix(path, RESTMappingV3.PREFIX_MINECRAFT); + } + + public static boolean isMinecraftOrReplayLitePrefix(String path) { + return isMinecraftApiPrefix(path) || matchesPrefix(path, RESTMappingV1.PREFIX_REPLAY_LITE); + } + + public static boolean isVersion1Prefix(String path) { + return matchesPrefix(path, RESTMappingV1.V1); + } + + public static boolean isVersion2MinecraftPrefix(String path) { + return matchesPrefix(path, RESTMappingV2.PREFIX_MINECRAFT); + } + + public static boolean isVersion3Prefix(String path) { + return matchesPrefix(path, RESTMappingV3.PREFIX); + } + + public static boolean isPanelChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_PANEL); + } + + public static boolean isPublicChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_PUBLIC); + } + + public static boolean isPublicReplayLiteChild(String path) { + return matchesChild(path, RESTMappingV1.PUBLIC_REPLAY_LITE); + } + + public static boolean isReplayLiteChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_REPLAY_LITE); + } + + public static boolean isAdminChild(String path) { + return matchesChild(path, RESTMappingV1.PREFIX_ADMIN); + } + + public static boolean isAdminAuthChild(String path) { + return matchesChild(path, RESTMappingV1.ADMIN_AUTH); + } + + private static boolean matchesExactOrChild(String path, String base) { + return path != null && (path.equals(base) || path.startsWith(base + "/")); + } + + private static boolean matchesChild(String path, String base) { + return path != null && path.startsWith(base + "/"); + } + + private static boolean matchesPrefix(String path, String base) { + return path != null && path.startsWith(base); + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java b/src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java new file mode 100644 index 0000000..539dda3 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/scheduling/KeysetDrainer.java @@ -0,0 +1,40 @@ +package gg.modl.backend.infrastructure.scheduling; + +import java.util.List; +import java.util.function.Function; +import java.util.function.ToLongFunction; + +public final class KeysetDrainer { + + private KeysetDrainer() { + } + + public static long drain( + int maxPages, + int pageSize, + PageFinder finder, + Function, C> cursorExtractor, + ToLongFunction> pageConsumer + ) { + long total = 0; + C cursor = null; + int pages = 0; + while (pages++ < maxPages) { + List page = finder.find(cursor, pageSize); + if (page.isEmpty()) { + break; + } + total += pageConsumer.applyAsLong(page); + cursor = cursorExtractor.apply(page); + if (page.size() < pageSize) { + break; + } + } + return total; + } + + @FunctionalInterface + public interface PageFinder { + List find(C cursor, int limit); + } +} 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 672f1d0..bfcb8bd 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/CookieUtil.java @@ -22,12 +22,8 @@ public Cookie createSessionCookie(String sessionId) { } 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 cookie = baseCookie(cookieName, sessionId); cookie.setMaxAge((int) maxAgeSeconds); - cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); return cookie; } @@ -53,18 +49,23 @@ public List createExpiredSessionCookies(String cookieName) { } private Cookie createExpiredCookie(String cookieName, String domain) { - Cookie cookie = new Cookie(cookieName, ""); - cookie.setHttpOnly(true); - cookie.setSecure(authConfiguration.isCookieSecure()); - cookie.setPath("/"); + Cookie cookie = baseCookie(cookieName, ""); cookie.setMaxAge(0); - cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); if (domain != null) { cookie.setDomain(domain); } return cookie; } + private Cookie baseCookie(String name, String value) { + Cookie cookie = new Cookie(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(authConfiguration.isCookieSecure()); + cookie.setPath("/"); + cookie.setAttribute("SameSite", authConfiguration.isDevelopmentMode() ? "Lax" : "Strict"); + 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/DigestUtils.java b/src/main/java/gg/modl/backend/infrastructure/util/DigestUtils.java new file mode 100644 index 0000000..53e187d --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/util/DigestUtils.java @@ -0,0 +1,21 @@ +package gg.modl.backend.infrastructure.util; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class DigestUtils { + + private DigestUtils() { + } + + public static String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 algorithm not available", exception); + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java index 9e5e4a4..8afce98 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/HostExtractionUtil.java @@ -28,11 +28,7 @@ public String extractHost(@Nullable String originOrDomain) { @Nullable public String normalizeServerDomain(@Nullable String serverDomain) { - String host = extractHost(serverDomain); - if (host == null || host.isBlank()) { - return null; - } - return host; + return extractHost(serverDomain); } public Set parseCommaSeparated(@Nullable String values) { 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 0ecad2d..8bb5708 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/IdGenerator.java @@ -1,7 +1,7 @@ package gg.modl.backend.infrastructure.util; +import gg.modl.backend.infrastructure.rest.RequestUtil; import java.security.SecureRandom; -import java.util.Base64; import org.springframework.stereotype.Component; @Component @@ -13,9 +13,7 @@ public int nextSixDigitInt() { } public String generateToken() { - byte[] bytes = new byte[32]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + return RequestUtil.generateSecureToken(32); } public static String generateShortId() { diff --git a/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java b/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java index 97eff24..5116535 100644 --- a/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java +++ b/src/main/java/gg/modl/backend/infrastructure/util/MongoKeyUtils.java @@ -59,10 +59,6 @@ public static void validateUpdatePath(String path) { } } - /** - * Resolve a field key to a human-readable label using the provided mapping. - * Falls back to formatting the key directly if no label mapping exists. - */ public static String resolveFieldLabel(String key, Map fieldLabels) { if (fieldLabels != null && fieldLabels.containsKey(key)) { return fieldLabels.get(key); @@ -70,10 +66,6 @@ public static String resolveFieldLabel(String key, Map fieldLabe return formatFieldKey(key); } - /** - * Format a raw field key (e.g. "myField_name") into a human-readable label - * (e.g. "My Field Name"). - */ public static String formatFieldKey(String key) { if (key == null || key.isBlank()) { return key; diff --git a/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java b/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java deleted file mode 100644 index 606286e..0000000 --- a/src/main/java/gg/modl/backend/infrastructure/util/SafeConvertUtil.java +++ /dev/null @@ -1,12 +0,0 @@ -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/util/UuidUtils.java b/src/main/java/gg/modl/backend/infrastructure/util/UuidUtils.java new file mode 100644 index 0000000..8a26f6f --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/util/UuidUtils.java @@ -0,0 +1,33 @@ +package gg.modl.backend.infrastructure.util; + +import java.util.Locale; +import java.util.UUID; + +public final class UuidUtils { + + private static final String UNDASHED_UUID_PATTERN = "^[0-9a-fA-F]{32}$"; + private static final String DASH_INSERT_PATTERN = "(.{8})(.{4})(.{4})(.{4})(.{12})"; + private static final String DASH_INSERT_REPLACEMENT = "$1-$2-$3-$4-$5"; + + private UuidUtils() { + } + + public static String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } + + public static String dashed(String raw) { + if (raw == null) { + return null; + } + String candidate = raw.trim(); + if (candidate.matches(UNDASHED_UUID_PATTERN)) { + candidate = candidate.replaceFirst(DASH_INSERT_PATTERN, DASH_INSERT_REPLACEMENT); + } + try { + return UUID.fromString(candidate).toString(); + } catch (IllegalArgumentException invalid) { + return null; + } + } +} diff --git a/src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java b/src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java new file mode 100644 index 0000000..48cc9a4 --- /dev/null +++ b/src/main/java/gg/modl/backend/infrastructure/validation/BeanValidationRunner.java @@ -0,0 +1,22 @@ +package gg.modl.backend.infrastructure.validation; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class BeanValidationRunner { + + private final Validator validator; + + public 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/infrastructure/validation/RegExpConstants.java b/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java index 101b76e..76bed42 100644 --- a/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java +++ b/src/main/java/gg/modl/backend/infrastructure/validation/RegExpConstants.java @@ -3,6 +3,7 @@ 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.:]+)$"; + + private RegExpConstants() { + } } diff --git a/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java b/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java index 1795424..ae77bef 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/KnowledgebaseProtoMapper.java @@ -1,6 +1,7 @@ package gg.modl.backend.knowledgebase.controller; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.addAll; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; @@ -17,6 +18,7 @@ import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoriesResponse; import gg.modl.proto.modl.v1.PublicKnowledgebaseCategoryWithArticlesResponse; import java.util.List; +import java.util.function.Function; import org.springframework.stereotype.Component; @Component @@ -71,7 +73,7 @@ public KnowledgebaseSearchResponse toSearchResponse(List a public PanelKnowledgebaseCategoriesResponse toPanelCategoriesResponse( List categories, - java.util.function.Function> articlesForCategory + Function> articlesForCategory ) { PanelKnowledgebaseCategoriesResponse.Builder builder = PanelKnowledgebaseCategoriesResponse.newBuilder(); addAll(categories, @@ -82,7 +84,7 @@ public PanelKnowledgebaseCategoriesResponse toPanelCategoriesResponse( public PublicKnowledgebaseCategoriesResponse toPublicCategoriesResponse( List categories, - java.util.function.Function> articlesForCategory + Function> articlesForCategory ) { PublicKnowledgebaseCategoriesResponse.Builder builder = PublicKnowledgebaseCategoriesResponse.newBuilder(); addAll(categories, @@ -136,8 +138,4 @@ private KnowledgebaseArticleStubResponse toArticleStub(KnowledgebaseArticle arti .setOrdinal(article.getOrdinal()) .build(); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } 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 2a2d8ab..84f2108 100644 --- a/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java +++ b/src/main/java/gg/modl/backend/knowledgebase/controller/PanelKnowledgebaseController.java @@ -1,5 +1,6 @@ package gg.modl.backend.knowledgebase.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.knowledgebase.data.KnowledgebaseArticle; @@ -20,6 +21,7 @@ import gg.modl.proto.modl.v1.UpdateCategoryRequest; import jakarta.servlet.http.HttpServletRequest; import java.util.Collections; +import java.util.List; import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -35,6 +37,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_KNOWLEDGEBASE) +@RequiresPanelPermission(view = "admin.settings.view.content", modify = "admin.settings.modify.content") @RequiredArgsConstructor public class PanelKnowledgebaseController { private final KnowledgebaseCategoryService categoryService; @@ -46,7 +49,7 @@ public class PanelKnowledgebaseController { public ResponseEntity getCategories(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); var categories = categoryService.getAllCategories(server); - Map> articlesByCategory = + Map> articlesByCategory = articleService.getAllArticlesGroupedByCategory(server); return ResponseEntity.ok(mapper.toPanelCategoriesResponse( diff --git a/src/main/java/gg/modl/backend/log/controller/PanelLogController.java b/src/main/java/gg/modl/backend/log/controller/PanelLogController.java index 899220c..dd582a5 100644 --- a/src/main/java/gg/modl/backend/log/controller/PanelLogController.java +++ b/src/main/java/gg/modl/backend/log/controller/PanelLogController.java @@ -2,6 +2,7 @@ import gg.modl.backend.log.dto.response.SystemLogResponse; import gg.modl.backend.log.service.LogService; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -21,6 +22,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_LOGS) +@RequiresPanelPermission("admin.audit.view.logs") @RequiredArgsConstructor @Validated public class PanelLogController { diff --git a/src/main/java/gg/modl/backend/log/data/SystemLog.java b/src/main/java/gg/modl/backend/log/data/ServerLog.java similarity index 97% rename from src/main/java/gg/modl/backend/log/data/SystemLog.java rename to src/main/java/gg/modl/backend/log/data/ServerLog.java index c6e8f9c..a500506 100644 --- a/src/main/java/gg/modl/backend/log/data/SystemLog.java +++ b/src/main/java/gg/modl/backend/log/data/ServerLog.java @@ -16,7 +16,7 @@ @AllArgsConstructor @Builder @GenerateMongoFields(className = "ServerLogFields") -public class SystemLog { +public class ServerLog { @Id private String id; 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 7fbb718..8a8369d 100644 --- a/src/main/java/gg/modl/backend/log/service/LogService.java +++ b/src/main/java/gg/modl/backend/log/service/LogService.java @@ -1,7 +1,7 @@ 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.data.ServerLog; import gg.modl.backend.log.dto.response.SystemLogResponse; import gg.modl.backend.server.data.Server; import java.util.Date; @@ -23,7 +23,7 @@ public class LogService { public List getLogs(Server server, int limit) { int safeLimit = Math.max(1, Math.min(limit, MAX_LIMIT)); - List logs = serverLogRepository.findRecent(server, safeLimit); + List logs = serverLogRepository.findRecent(server, safeLimit); return logs.stream() .map(l -> new SystemLogResponse( @@ -45,7 +45,7 @@ public void recordStaffAction(Server server, String source, String description) } private void write(Server server, String description, String level, String source) { - SystemLog entry = SystemLog.builder() + ServerLog entry = ServerLog.builder() .description(description) .level(level) .source(source != null && !source.isBlank() ? source : SOURCE_SYSTEM) diff --git a/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java b/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java index e5a1762..0d0e7cc 100644 --- a/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java +++ b/src/main/java/gg/modl/backend/log/service/PanelActionAuditor.java @@ -2,7 +2,7 @@ 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.staff.service.StaffLookupCache; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -10,7 +10,7 @@ @RequiredArgsConstructor public class PanelActionAuditor { private final LogService logService; - private final StaffService staffService; + private final StaffLookupCache staffLookupCache; public void recordStaffAction(Server server, String actorEmail, String description) { logService.recordStaffAction(server, resolveActor(server, actorEmail), description); @@ -24,7 +24,7 @@ private String resolveActor(Server server, String actorEmail) { if (actorEmail == null || actorEmail.isBlank()) { return "Unknown"; } - return staffService.getStaffByEmail(server, actorEmail) + return staffLookupCache.findByEmail(server, actorEmail) .map(Staff::getUsername) .filter(username -> username != null && !username.isBlank()) .orElse(actorEmail); diff --git a/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java b/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java index b65ae25..823778d 100644 --- a/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java +++ b/src/main/java/gg/modl/backend/migration/controller/MigrationProtoMapper.java @@ -1,11 +1,12 @@ package gg.modl.backend.migration.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.toTimestamp; import gg.modl.backend.migration.data.MigrationStatus; +import gg.modl.backend.migration.service.MigrationService.MigrationOperationResult; import gg.modl.proto.modl.v1.MigrationOperationResponse; import gg.modl.proto.modl.v1.MigrationStatusResponse; -import java.util.Map; import org.jetbrains.annotations.Nullable; import org.springframework.stereotype.Component; @@ -47,20 +48,17 @@ public MigrationStatusResponse toStatusResponse(@Nullable MigrationStatus status return builder.build(); } - public MigrationOperationResponse toOperationResponse(Map result) { + public MigrationOperationResponse toOperationResponse(MigrationOperationResult result) { MigrationOperationResponse.Builder builder = MigrationOperationResponse.newBuilder() - .setSuccess(Boolean.TRUE.equals(result.get("success"))); - Object taskId = result.get("taskId"); - if (taskId != null) { - builder.setTaskId(taskId.toString()); + .setSuccess(result.success()); + if (result.taskId() != null) { + builder.setTaskId(result.taskId()); } - Object message = result.get("message"); - if (message != null) { - builder.setMessage(message.toString()); + if (result.message() != null) { + builder.setMessage(result.message()); } - Object error = result.get("error"); - if (error != null) { - builder.setError(error.toString()); + if (result.error() != null) { + builder.setError(result.error()); } return builder.build(); } @@ -80,8 +78,4 @@ private MigrationStatusResponse.MigrationProgress toProgress(MigrationStatus.Mig } return builder.build(); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } 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 0767a87..5c96b2e 100644 --- a/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java +++ b/src/main/java/gg/modl/backend/migration/controller/MinecraftMigrationController.java @@ -1,18 +1,18 @@ 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.migration.service.MigrationService.FileSizeError; +import gg.modl.backend.migration.service.MigrationUploadService; +import gg.modl.backend.migration.service.MigrationUploadService.UploadResult; 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.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -26,7 +26,7 @@ @RequiredArgsConstructor public class MinecraftMigrationController { private final MigrationService migrationService; - private final MigrationProcessor migrationProcessor; + private final MigrationUploadService migrationUploadService; @PostMapping("/upload") public ResponseEntity uploadMigrationFile( @@ -34,51 +34,37 @@ public ResponseEntity uploadMigrationFile( 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() - )); + UploadResult result = migrationUploadService.beginUpload(server, file); + return switch (result) { + case UploadResult.Success success -> { + Map body = Map.of( + "success", true, + "message", "Migration file uploaded successfully. Processing started.", + "fileSize", success.fileSize()); + yield ResponseEntity.ok(body); + } + case UploadResult.FileTooLarge fileTooLarge -> { + FileSizeError error = fileTooLarge.error(); + Map body = Map.of( + "error", error.error(), + "message", error.message(), + "fileSize", error.fileSize(), + "limit", error.limit()); + yield ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(body); + } + case UploadResult.ProcessingBusy ignored -> { + Map body = Map.of( + "error", "Migration processing is busy", + "message", "The server is processing other migrations. Please try again shortly."); + yield ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } + case UploadResult.StartFailed ignored -> { + Map body = Map.of( + "error", "Migration failed to start", + "message", "The migration could not be started. Please try again."); + yield ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } + }; } @PostMapping("/progress") 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 cccdce5..265785c 100644 --- a/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java +++ b/src/main/java/gg/modl/backend/migration/controller/PanelMigrationController.java @@ -1,15 +1,16 @@ package gg.modl.backend.migration.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; 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.migration.service.MigrationService.MigrationOperationResult; 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; @@ -20,6 +21,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_MIGRATION) +@RequiresPanelPermission(view = "admin.settings.view.migration", modify = "admin.settings.modify.migration") @RequiredArgsConstructor public class PanelMigrationController { private final MigrationService migrationService; @@ -41,20 +43,20 @@ public ResponseEntity startMigration( HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); - Map result = migrationService.startMigration(server, startRequest.getMigrationType()); + MigrationOperationResult 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); + MigrationOperationResult result = migrationService.cancelMigration(server); return toResponse(result); } - private ResponseEntity toResponse(Map result) { + private ResponseEntity toResponse(MigrationOperationResult result) { MigrationOperationResponse response = mapper.toOperationResponse(result); - if (Boolean.FALSE.equals(result.get("success"))) { + if (!result.success()) { return ResponseEntity.badRequest().body(response); } return ResponseEntity.ok(response); diff --git a/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java b/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java deleted file mode 100644 index af9e042..0000000 --- a/src/main/java/gg/modl/backend/migration/dto/MigrationPlayerData.java +++ /dev/null @@ -1,46 +0,0 @@ -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/service/MigrationMessages.java b/src/main/java/gg/modl/backend/migration/service/MigrationMessages.java new file mode 100644 index 0000000..8cacbd8 --- /dev/null +++ b/src/main/java/gg/modl/backend/migration/service/MigrationMessages.java @@ -0,0 +1,13 @@ +package gg.modl.backend.migration.service; + +final class MigrationMessages { + private MigrationMessages() { + } + + static String truncate(String value, int maxLength) { + if (value.length() > maxLength) { + return value.substring(0, maxLength - 1) + "…"; + } + return value; + } +} 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 578d71c..8cf8579 100644 --- a/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java +++ b/src/main/java/gg/modl/backend/migration/service/MigrationProcessor.java @@ -8,34 +8,18 @@ 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; @@ -48,6 +32,7 @@ public class MigrationProcessor { private final MigrationService migrationService; private final MigrationValidator validator; private final ObjectMapper objectMapper; + private final MigrationRecordMapper recordMapper; private static final int BATCH_SIZE = 500; private static final int PROGRESS_UPDATE_INTERVAL = 1000; @@ -192,9 +177,9 @@ private boolean streamPlayers(Server server, JsonParser parser, ObjectMapper map } private boolean drainBatch(Server server, List> batch, ProgressCounters counters) { - int[] results = processBatch(server, batch); - counters.addProcessed(results[0]); - counters.addSkipped(results[1]); + BatchResult results = processBatch(server, batch); + counters.addProcessed(results.processed()); + counters.addSkipped(results.skipped()); batch.clear(); if (counters.dueForAnnounce(PROGRESS_UPDATE_INTERVAL)) { @@ -244,14 +229,11 @@ private static String processingMessage(int processed, Integer 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; + return MigrationMessages.truncate(prefix + (detail == null ? "unknown error" : detail), + MAX_FAILURE_MESSAGE_LENGTH); } - private int[] processBatch(Server server, List> batch) { + private BatchResult processBatch(Server server, List> batch) { int processed = 0; int skipped = 0; @@ -260,12 +242,12 @@ private int[] processBatch(Server server, List> batch) { for (Map playerMap : batch) { Object uuidObj = playerMap.get("minecraftUuid"); - if (uuidObj == null || !(uuidObj instanceof String)) { + if (!(uuidObj instanceof String uuidStr)) { skipped++; continue; } - String uuid = validator.normalizeUuid((String) uuidObj); + String uuid = validator.normalizeUuid(uuidStr); if (!validator.isValidUuid(uuid)) { skipped++; continue; @@ -276,7 +258,7 @@ private int[] processBatch(Server server, List> batch) { } if (uuids.isEmpty()) { - return new int[]{0, skipped}; + return new BatchResult(0, skipped); } List existingPlayers = playerRepository.findByMinecraftUuids(server, @@ -295,12 +277,12 @@ private int[] processBatch(Server server, List> batch) { Player existing = existingMap.get(uuid); if (existing != null) { - Update update = buildMergeUpdate(existing, playerMap); + Update update = recordMapper.buildMergeUpdate(existing, playerMap); if (update != null) { mergeUpdates.put(UUID.fromString(uuid), update); } } else { - Player newPlayer = buildNewPlayer(uuid, playerMap); + Player newPlayer = recordMapper.buildNewPlayer(uuid, playerMap); if (newPlayer != null) { toInsert.add(newPlayer); } @@ -320,483 +302,10 @@ private int[] processBatch(Server server, List> batch) { 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; + return new BatchResult(processed, skipped); } - @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 record BatchResult(int processed, int skipped) {} private static final class ProgressCounters { private int processed; diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java b/src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java new file mode 100644 index 0000000..0e65e72 --- /dev/null +++ b/src/main/java/gg/modl/backend/migration/service/MigrationRecordMapper.java @@ -0,0 +1,503 @@ +package gg.modl.backend.migration.service; + +import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.validation.SafeUrls; +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 java.nio.charset.StandardCharsets; +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 org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class MigrationRecordMapper { + private final MigrationValidator validator; + + public 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; + } + } + + public 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 items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + 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 items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + 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 parseIpAddresses(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + 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 loginItems) { + for (Object loginObj : loginItems) { + 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; + } + + private List parsePunishments(Object data) { + List result = new ArrayList<>(); + if (!(data instanceof List items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map map)) { + continue; + } + + 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 number) { + typeOrdinal = number.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 noteItems) { + for (Object noteObj : noteItems) { + if (noteObj instanceof Map noteMap) { + 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 ticketItems) { + for (Object ticketId : ticketItems) { + if (ticketId instanceof String ticketIdStr) { + attachedTicketIds.add(ticketIdStr); + } + } + } + + Map punishmentData = new HashMap<>(); + Object dataObj = map.get("data"); + if (dataObj instanceof Map dataMap) { + for (Map.Entry entry : dataMap.entrySet()) { + if (entry.getKey() instanceof String key) { + punishmentData.put(key, 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 durationNumber) { + punishmentData.put("duration", durationNumber.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 items)) { + return result; + } + + for (Object item : items) { + if (!(item instanceof Map m)) { + continue; + } + + 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 dataMap) { + modData = new HashMap<>(); + for (Map.Entry entry : dataMap.entrySet()) { + if (entry.getKey() instanceof String key) { + modData.put(key, 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 items)) { + return result; + } + + for (Object item : items) { + if (item instanceof String str) { + String text = validator.sanitizeString(str, 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 m) { + 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 Map parseData(Object data) { + if (data instanceof Map dataMap) { + Map result = new HashMap<>(); + for (Map.Entry entry : dataMap.entrySet()) { + if (entry.getKey() instanceof String key) { + result.put(key, entry.getValue()); + } + } + return result; + } + return new HashMap<>(); + } +} 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 8a762c1..6f82246 100644 --- a/src/main/java/gg/modl/backend/migration/service/MigrationService.java +++ b/src/main/java/gg/modl/backend/migration/service/MigrationService.java @@ -16,7 +16,6 @@ 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; @@ -37,6 +36,23 @@ public class MigrationService { public record CooldownState(boolean onCooldown, @Nullable Long remainingTime) {} + public record MigrationOperationResult(boolean success, @Nullable String taskId, + @Nullable String message, @Nullable String error) { + static MigrationOperationResult failure(String error) { + return new MigrationOperationResult(false, null, null, error); + } + + static MigrationOperationResult started(String taskId, String message) { + return new MigrationOperationResult(true, taskId, message, null); + } + + static MigrationOperationResult succeeded(String message) { + return new MigrationOperationResult(true, null, message, null); + } + } + + public record FileSizeError(String error, String message, long fileSize, long limit) {} + 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" @@ -73,9 +89,9 @@ public CooldownState checkCooldown(Server server) { return new CooldownState(false, null); } - public Map startMigration(Server server, String migrationType) { + public MigrationOperationResult startMigration(Server server, String migrationType) { if (!VALID_TYPES.contains(migrationType.toLowerCase())) { - return Map.of("success", false, "error", "Invalid migration type"); + return MigrationOperationResult.failure("Invalid migration type"); } Date now = new Date(); @@ -84,12 +100,13 @@ public Map startMigration(Server server, String migrationType) { "Migration timed out and was automatically cancelled."); if (migrationRepository.existsActiveMigration(server, staleBefore)) { - return Map.of("success", false, "error", "A migration is already in progress"); + return MigrationOperationResult.failure("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."); + return MigrationOperationResult.failure( + "Migration on cooldown. Please wait before starting another migration."); } String taskId = UUID.randomUUID().toString(); @@ -114,18 +131,15 @@ public Map startMigration(Server server, String migrationType) { .setType(type) .build()); - return Map.of( - "success", true, - "taskId", taskId, - "message", "Migration task initiated. Waiting for Minecraft server to process." - ); + return MigrationOperationResult.started(taskId, + "Migration task initiated. Waiting for Minecraft server to process."); } - public Map cancelMigration(Server server) { + public MigrationOperationResult cancelMigration(Server server) { MigrationStatus activeMigration = migrationRepository.findActiveMigration(server).orElse(null); if (activeMigration == null) { - return Map.of("success", false, "error", "No active migration to cancel"); + return MigrationOperationResult.failure("No active migration to cancel"); } boolean cooldownExempt = "building_json".equals(activeMigration.getStatus()); @@ -133,13 +147,13 @@ public Map cancelMigration(Server server) { migrationRepository.cancelMigration(server, activeMigration.getId(), "Cancelled by administrator", new Date(), "Migration cancelled by administrator", cooldownExempt); - return Map.of("success", true, "message", "Migration cancelled successfully"); + return MigrationOperationResult.succeeded("Migration cancelled successfully"); } - public Map validateFileSize(Server server, MultipartFile file) { + public Optional validateFileSize(Server server, MultipartFile file) { long fileSizeLimit = getFileSizeLimit(server); if (file.getSize() <= fileSizeLimit) { - return null; + return Optional.empty(); } double fileSizeMB = file.getSize() / (1024.0 * 1024.0); @@ -149,12 +163,11 @@ public Map validateFileSize(Server server, MultipartFile file) { "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 - ); + return Optional.of(new FileSizeError( + "Migration file exceeds size limit", + String.format("File size (%.2fMB) exceeds the limit of %.2fMB.", fileSizeMB, limitMB), + file.getSize(), + fileSizeLimit)); } public void updateProgress(Server server, UpdateProgressRequest request) { @@ -192,10 +205,7 @@ public void updateProgress(Server server, UpdateProgressRequest request) { } private static String clampMessage(String message) { - if (message == null || message.length() <= MAX_MESSAGE_LENGTH) { - return message; - } - return message.substring(0, MAX_MESSAGE_LENGTH - 1) + "…"; + return message == null ? null : MigrationMessages.truncate(message, MAX_MESSAGE_LENGTH); } public long getFileSizeLimit(Server server) { diff --git a/src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java b/src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java new file mode 100644 index 0000000..5aa43d4 --- /dev/null +++ b/src/main/java/gg/modl/backend/migration/service/MigrationUploadService.java @@ -0,0 +1,66 @@ +package gg.modl.backend.migration.service; + +import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.migration.dto.UpdateProgressRequest; +import gg.modl.backend.migration.service.MigrationService.FileSizeError; +import gg.modl.backend.server.data.Server; +import java.nio.file.Path; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class MigrationUploadService { + private final MigrationService migrationService; + private final MigrationProcessor migrationProcessor; + + public UploadResult beginUpload(Server server, MultipartFile file) { + 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"); + } + + Optional sizeError = migrationService.validateFileSize(server, file); + if (sizeError.isPresent()) { + return new UploadResult.FileTooLarge(sizeError.get()); + } + + 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 new UploadResult.ProcessingBusy(); + } catch (RuntimeException e) { + migrationService.discardUpload(server, filePath, "Migration failed to start."); + return new UploadResult.StartFailed(); + } + + return new UploadResult.Success(file.getSize()); + } + + public sealed interface UploadResult { + record Success(long fileSize) implements UploadResult {} + + record FileTooLarge(FileSizeError error) implements UploadResult {} + + record ProcessingBusy() implements UploadResult {} + + record StartFailed() implements UploadResult {} + } +} 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 b194b55..171aac0 100644 --- a/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java +++ b/src/main/java/gg/modl/backend/migration/validation/MigrationValidator.java @@ -1,7 +1,6 @@ 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; @@ -41,15 +40,7 @@ public boolean isValidUuid(String uuid) { return false; } - if (UUID_PATTERN.matcher(uuid).matches()) { - return true; - } - - if (UUID_NO_DASHES_PATTERN.matcher(uuid).matches()) { - return true; - } - - return false; + return UUID_PATTERN.matcher(uuid).matches() || UUID_NO_DASHES_PATTERN.matcher(uuid).matches(); } public String normalizeUuid(String uuid) { @@ -90,31 +81,20 @@ public Date parseDate(Object dateObj) { return null; } - if (dateObj instanceof Date) { - return (Date) dateObj; + if (dateObj instanceof Date date) { + return date; } - 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 Number number) { + return epochToDate(number.longValue()); } - if (dateObj instanceof String) { - String dateStr = (String) dateObj; + if (dateObj instanceof String dateStr) { 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); - } + return epochToDate(Long.parseLong(dateStr)); } catch (NumberFormatException ex) { log.warn("Unable to parse date: {}", dateStr); return null; @@ -125,17 +105,101 @@ public Date parseDate(Object dateObj) { return null; } + private Date epochToDate(long timestamp) { + if (timestamp > 100_000_000_000L) { + return new Date(timestamp); + } + return new Date(timestamp * 1000); + } + public boolean isValidIpAddress(String ip) { if (ip == null || ip.isBlank()) { return false; } - try { - InetAddress.getByName(ip); - return true; - } catch (Exception e) { + return ip.indexOf(':') >= 0 ? isValidIpv6(ip) : isValidIpv4(ip); + } + + private static boolean isValidIpv4(String value) { + String[] octets = value.split("\\.", -1); + if (octets.length != 4) { + return false; + } + for (String octet : octets) { + if (!isValidIpv4Octet(octet)) { + return false; + } + } + return true; + } + + private static boolean isValidIpv4Octet(String octet) { + int length = octet.length(); + if (length < 1 || length > 3) { + return false; + } + for (int i = 0; i < length; i++) { + char c = octet.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + if (length > 1 && octet.charAt(0) == '0') { + return false; + } + return Integer.parseInt(octet) <= 255; + } + + private static boolean isValidIpv6(String value) { + int compressionIndex = value.indexOf("::"); + boolean compressed = compressionIndex >= 0; + if (compressed && value.indexOf("::", compressionIndex + 1) >= 0) { + return false; + } + + String[] sides = compressed + ? new String[]{value.substring(0, compressionIndex), value.substring(compressionIndex + 2)} + : new String[]{value}; + + int groupCount = 0; + for (int side = 0; side < sides.length; side++) { + if (sides[side].isEmpty()) { + continue; + } + String[] groups = sides[side].split(":", -1); + for (int i = 0; i < groups.length; i++) { + boolean lastGroup = side == sides.length - 1 && i == groups.length - 1; + String group = groups[i]; + if (group.indexOf('.') >= 0) { + if (!lastGroup || !isValidIpv4(group)) { + return false; + } + groupCount += 2; + } else { + if (!isValidHextet(group)) { + return false; + } + groupCount += 1; + } + } + } + + return compressed ? groupCount < 8 : groupCount == 8; + } + + private static boolean isValidHextet(String group) { + int length = group.length(); + if (length < 1 || length > 4) { return false; } + for (int i = 0; i < length; i++) { + char c = group.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; } public record ValidationResult(boolean valid, String error, int playerCount) { diff --git a/src/main/java/gg/modl/backend/player/PlayerService.java b/src/main/java/gg/modl/backend/player/PlayerService.java index 6d8697f..91c4cdd 100644 --- a/src/main/java/gg/modl/backend/player/PlayerService.java +++ b/src/main/java/gg/modl/backend/player/PlayerService.java @@ -4,6 +4,7 @@ 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.PlayerDataView; import gg.modl.backend.player.data.UsernameEntry; import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; @@ -31,6 +32,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import gg.modl.backend.infrastructure.util.IdGenerator; +import gg.modl.backend.infrastructure.util.UuidUtils; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; @@ -64,7 +66,7 @@ public List searchPlayers(Server server, String searchTerm) List players; if (isUuid(normalizedSearch)) { - players = playerRepository.findByMinecraftUuid(server, normalizeUuid(normalizedSearch)) + players = playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(normalizedSearch)) .map(List::of) .orElseGet(List::of); } else { @@ -111,8 +113,7 @@ private PlayerSearchResult toPlayerSearchResult(Server server, Player player) { String status = calculatePlayerStatus(server, player); Date lastOnline = getLastOnline(player); - boolean isOnline = Boolean.TRUE.equals( - player.getData() != null ? player.getData().get("isOnline") : null); + boolean isOnline = player.data().isOnline(); return new PlayerSearchResult( player.getMinecraftUuid().toString(), @@ -128,7 +129,7 @@ private String calculatePlayerStatus(Server server, Player player) { for (Punishment punishment : punishments) { if (statusCalculator.isPunishmentActive(punishment)) { PunishmentType pt = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.getTypeOrdinal()).orElse(null); - String category = statusCalculator.getEffectiveCategory(pt, punishment.getData()); + String category = statusCalculator.getEffectiveCategory(pt, punishment.data()); if (EnforcementCategory.BAN.name().equals(category)) { return "Banned"; } @@ -233,19 +234,11 @@ private long getLastLoginMillis(Player player) { } private Date getLastOnline(Player player) { - if (player.getData() == null) { - return null; - } - Object lastLogin = player.getData().get("lastLogin"); - if (lastLogin instanceof Date) { - return (Date) lastLogin; - } - return null; + return player.data().lastLogin(); } private boolean isOnline(Player player) { - Object isOnline = player.getData() != null ? player.getData().get("isOnline") : null; - return Boolean.TRUE.equals(isOnline); + return player.data().isOnline(); } private boolean isUuid(String value) { @@ -290,13 +283,10 @@ private PlayerDetailResponse buildPlayerDetailResponse(Server server, Player pla IPEntry latestIp = sanitizedIps.isEmpty() ? null : sanitizedIps.get(sanitizedIps.size() - 1); - String lastServer = player.getData() != null ? (String) player.getData().get("lastServer") : null; + String lastServer = player.data().lastServer(); - Object playtimeObj = player.getData() != null ? player.getData().get("totalPlaytimeSeconds") : null; - double playtimeHours = 0; - if (playtimeObj instanceof Number) { - playtimeHours = ((Number) playtimeObj).doubleValue() / 3600.0; - } + Number playtime = player.data().totalPlaytimeSeconds(); + double playtimeHours = playtime != null ? playtime.doubleValue() / 3600.0 : 0; return new PlayerDetailResponse( player.getId(), @@ -305,7 +295,7 @@ private PlayerDetailResponse buildPlayerDetailResponse(Server server, Player pla capNotes(player.getNotes()), sanitizedIps, punishmentResponses, - player.getData(), + player.data().asMap(), status.social(), status.gameplay(), status.socialPoints(), @@ -465,9 +455,6 @@ public Player addIp(Server server, UUID minecraftUuid, String ipAddress) { return player; } - /** - * @return true if this IP was newly added, false if it already existed - */ private boolean addIpToPlayer(Player player, String ipAddress, Map ipInfo) { if (ipAddress == null || ipAddress.isBlank()) { return false; @@ -532,7 +519,7 @@ public void updateIpGeoData(Server server, String minecraftUuid, String ipAddres return; } - Player player = playerRepository.findByMinecraftUuid(server, normalizeUuid(minecraftUuid)).orElse(null); + Player player = playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(minecraftUuid)).orElse(null); if (player == null) { return; } @@ -567,31 +554,20 @@ private boolean updatePlayerOnLogin(Player player, String username, String ip, M private void updatePlayerDataOnLogin(Player player, String skinHash, String serverName) { Date now = new Date(); - Map data = ensureData(player); - data.put("lastLogin", now); - data.put("isOnline", true); + PlayerDataView data = player.data(); + data.setLastLogin(now); + data.setOnline(true); - if (!data.containsKey("firstJoin")) { - data.put("firstJoin", now); + if (!data.hasFirstJoin()) { + data.setFirstJoin(now); } if (skinHash != null && !skinHash.isBlank()) { - data.put("lastSkinHash", skinHash); + data.setLastSkinHash(skinHash); } if (serverName != null && !serverName.isBlank()) { - data.put("lastServer", serverName); - } - } - - private Map ensureData(Player player) { - if (player.getData() == null) { - player.setData(new HashMap<>()); + data.setLastServer(serverName); } - return player.getData(); - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java index d7bffda..b53aec1 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftChatLogController.java @@ -15,7 +15,6 @@ import jakarta.validation.constraints.Min; import java.util.Map; 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; @@ -29,7 +28,6 @@ @RestController @RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j @Validated public class MinecraftChatLogController { diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java index dc5f5d9..3b3c621 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationController.java @@ -1,6 +1,7 @@ package gg.modl.backend.player.controller; import gg.modl.backend.player.dto.request.AcknowledgeNotificationsRequest; +import gg.modl.backend.player.dto.response.AcknowledgeResult; import gg.modl.backend.player.service.MinecraftPlayerService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -27,7 +28,11 @@ public ResponseEntity> acknowledgeNotifications( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.acknowledgeNotifications(server, request); - return ResponseEntity.status(response.status()).body(response.body()); + AcknowledgeResult result = minecraftPlayerService.acknowledgeNotifications(server, request); + return ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", result.message() + )); } } \ No newline at end of file diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java index 56d8f29..e7ddd8a 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftNotificationV3Controller.java @@ -32,14 +32,13 @@ public ResponseEntity acknowledgeNotifications( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.acknowledgeNotifications( + minecraftPlayerService.acknowledgeNotifications( server, request.getPlayerUuid(), request.getNotificationIdsList(), request.getAcknowledgedAt().isEmpty() ? null : request.getAcknowledgedAt() ); - return ResponseEntity.status(response.status()) - .body(MinecraftPlayerProtoMapper.toSimpleResponse(response.body())); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(true)); } } 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 667b8e9..a136080 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerController.java @@ -1,6 +1,15 @@ package gg.modl.backend.player.controller; import gg.modl.backend.player.dto.request.CreateNoteRequest; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PardonResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerProfileResult; import gg.modl.backend.player.service.MinecraftPlayerService; import gg.modl.backend.player.service.PlayerLookupService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; @@ -16,11 +25,10 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import java.util.LinkedHashMap; 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; @@ -34,7 +42,6 @@ @RestController @RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j @Validated public class MinecraftPlayerController { private final MinecraftPlayerService minecraftPlayerService; @@ -46,7 +53,7 @@ public ResponseEntity> login( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.login( + PlayerLoginResult result = minecraftPlayerService.login( server, UUID.fromString(request.minecraftUUID()), request.username(), @@ -55,7 +62,18 @@ public ResponseEntity> login( request.skinHash(), request.serverName() ); - return ResponseEntity.status(response.status()).body(response.body()); + + Map body = new LinkedHashMap<>(); + body.put("status", result.status()); + body.put("activePunishments", result.activePunishments()); + body.put("pendingNotifications", result.pendingNotifications()); + if (!result.pendingIpLookups().isEmpty()) { + body.put("pendingIpLookups", result.pendingIpLookups()); + } + if (!result.pendingStatWipes().isEmpty()) { + body.put("pendingStatWipes", result.pendingStatWipes()); + } + return ResponseEntity.status(result.status()).body(body); } @PostMapping("/disconnect") @@ -64,7 +82,8 @@ public ResponseEntity> disconnect( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.disconnect(server, request.minecraftUuid(), request.sessionDurationMs())); + boolean success = minecraftPlayerService.disconnect(server, request.minecraftUuid(), request.sessionDurationMs()).success(); + return ResponseEntity.ok(successBody(success)); } @PostMapping("/update-server") @@ -73,13 +92,17 @@ public ResponseEntity> updateServer( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.updateServer(server, request.minecraftUuid(), request.serverName())); + boolean success = minecraftPlayerService.updateServer(server, request.minecraftUuid(), request.serverName()).success(); + return ResponseEntity.ok(successBody(success)); } @GetMapping("/online") public ResponseEntity> getOnlinePlayers(HttpServletRequest httpRequest) { Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.getOnlinePlayers(server)); + return ResponseEntity.ok(Map.of( + "status", 200, + "players", minecraftPlayerService.getOnlinePlayers(server).players() + )); } @GetMapping("/{uuid}") @@ -90,8 +113,7 @@ public ResponseEntity> getPlayerByUuid( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit); - return ResponseEntity.status(response.status()).body(response.body()); + return renderProfile(playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit)); } @GetMapping @@ -101,8 +123,7 @@ public ResponseEntity> getPlayerByQuery( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang); - return ResponseEntity.status(response.status()).body(response.body()); + return renderFetch(playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang)); } @GetMapping("/by-name") @@ -112,8 +133,7 @@ public ResponseEntity> getPlayerByUsername( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUsername(server, username, queryMojang); - return ResponseEntity.status(response.status()).body(response.body()); + return renderFetch(playerLookupService.getPlayerByUsername(server, username, queryMojang)); } @PostMapping("/lookup") @@ -122,8 +142,14 @@ public ResponseEntity> lookupPlayer( 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()); + return switch (playerLookupService.lookupPlayer(server, request.query(), request.shouldQueryMojang())) { + case PlayerLookupResult.Found found -> ResponseEntity.ok(Map.of( + "status", 200, + "message", found.message(), + "data", found.data() + )); + case PlayerLookupResult.NotFound notFound -> notFound(notFound.message()); + }; } @PostMapping("/lookup-profile") @@ -134,9 +160,8 @@ public ResponseEntity> lookupProfile( 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()); + return renderProfile(playerLookupService.lookupProfile(server, request.query(), request.shouldQueryMojang(), + punishmentLimit, noteLimit)); } @PostMapping("/{uuid}/notes") @@ -146,9 +171,15 @@ public ResponseEntity> createPlayerNote( 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()); + return switch (minecraftPlayerService.createNote(server, uuid, request.text(), request.issuerName(), + request.issuerId())) { + case CreateNoteResult.Created created -> ResponseEntity.ok(Map.of( + "status", 200, + "success", true, + "message", created.message() + )); + case CreateNoteResult.NotFound notFound -> notFound(notFound.message()); + }; } @GetMapping("/{uuid}/linked-accounts") @@ -159,8 +190,20 @@ public ResponseEntity> getLinkedAccounts( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getLinkedAccounts(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); + return switch (playerLookupService.getLinkedAccounts(server, uuid, page, limit)) { + case LinkedAccountsResult.Found found -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("linkedAccounts", found.linkedAccounts()); + if (found.totalCount() != null) { + body.put("totalCount", found.totalCount()); + body.put("page", found.page()); + body.put("hasMore", found.hasMore()); + } + yield ResponseEntity.ok(body); + } + case LinkedAccountsResult.NotFound notFound -> notFound(notFound.message()); + }; } @GetMapping("/{uuid}/punishments") @@ -171,8 +214,18 @@ public ResponseEntity> getPlayerPunishments( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); + return switch (minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit)) { + case PaginatedPunishmentsResult.Found found -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("punishments", found.punishments()); + body.put("totalCount", found.totalCount()); + body.put("page", found.page()); + body.put("hasMore", found.hasMore()); + yield ResponseEntity.ok(body); + } + case PaginatedPunishmentsResult.NotFound notFound -> notFound(notFound.message()); + }; } @GetMapping("/{uuid}/notes") @@ -183,8 +236,18 @@ public ResponseEntity> getPlayerNotes( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerNotes(server, uuid, page, limit); - return ResponseEntity.status(response.status()).body(response.body()); + return switch (minecraftPlayerService.getPlayerNotes(server, uuid, page, limit)) { + case PaginatedNotesResult.Found found -> { + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("notes", found.notes()); + body.put("totalCount", found.totalCount()); + body.put("page", found.page()); + body.put("hasMore", found.hasMore()); + yield ResponseEntity.ok(body); + } + case PaginatedNotesResult.NotFound notFound -> notFound(notFound.message()); + }; } @GetMapping("/{uuid}/reports") @@ -193,7 +256,10 @@ public ResponseEntity> getPlayerReports( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.getPlayerReports(server, uuid)); + return ResponseEntity.ok(Map.of( + "status", 200, + "reports", minecraftPlayerService.getPlayerReports(server, uuid).reports() + )); } @PostMapping("/submit-ip-info") @@ -202,7 +268,7 @@ public ResponseEntity> submitIpInfo( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - return ResponseEntity.ok(minecraftPlayerService.submitIpInfo( + boolean success = minecraftPlayerService.submitIpInfo( server, request.minecraftUUID(), request.ip(), @@ -211,7 +277,8 @@ public ResponseEntity> submitIpInfo( request.asn(), request.proxy(), request.hosting() - )); + ).success(); + return ResponseEntity.ok(successBody(success)); } @PostMapping("/pardon") @@ -220,19 +287,58 @@ public ResponseEntity> pardonPlayer( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.pardonPlayer( + return switch (minecraftPlayerService.pardonPlayer( server, request.playerName(), request.punishmentType(), request.issuerName(), request.issuerId(), request.reason() - ); + )) { + case PardonResult.Pardoned pardoned -> ResponseEntity.ok(Map.of( + "status", 200, + "success", pardoned.success(), + "pardonedCount", pardoned.pardonedCount(), + "message", pardoned.message() + )); + case PardonResult.PlayerNotFound notFound -> notFound(notFound.message()); + }; + } - if (Objects.equals(response.get("status"), 404)) { - return ResponseEntity.status(404).body(response); - } - return ResponseEntity.ok(response); + private ResponseEntity> renderProfile(PlayerProfileResult result) { + return switch (result) { + case PlayerProfileResult.Found found -> ResponseEntity.ok(Map.of( + "status", 200, + "profile", found.profile() + )); + case PlayerProfileResult.NotFound notFound -> notFound(notFound.message()); + }; + } + + private ResponseEntity> renderFetch(PlayerFetchResult result) { + return switch (result) { + case PlayerFetchResult.Found found -> ResponseEntity.ok(Map.of( + "status", 200, + "message", found.message(), + "player", found.player() + )); + case PlayerFetchResult.NotFound notFound -> notFound(notFound.message()); + case PlayerFetchResult.InvalidRequest invalid -> ResponseEntity.status(400).body(Map.of( + "status", 400, + "message", invalid.message() + )); + }; + } + + private ResponseEntity> notFound(String message) { + return ResponseEntity.status(404).body(Map.of( + "status", 404, + "message", message + )); + } + + private Map successBody(boolean success) { + return Map.of("status", 200, "success", success); } public record LoginRequest( diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java index 2dc1837..0465d79 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerProtoMapper.java @@ -5,10 +5,10 @@ import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.legacyStruct; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOf; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.map; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalBoolean; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalInt; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalLong; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; @@ -16,6 +16,18 @@ import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.OnlinePlayersResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PardonResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerReportsResult; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.player.dto.response.SimplePunishmentView; import gg.modl.proto.modl.v1.Account; import gg.modl.proto.modl.v1.IPEntry; import gg.modl.proto.modl.v1.LinkedAccountsResponse; @@ -48,164 +60,180 @@ public final class MinecraftPlayerProtoMapper { private MinecraftPlayerProtoMapper() { } - static PlayerLoginResponse toPlayerLoginResponse(Map body) { + static PlayerLoginResponse toPlayerLoginResponse(PlayerLoginResult result) { PlayerLoginResponse.Builder response = PlayerLoginResponse.newBuilder() - .setStatus(intValue(body.get("status"))); + .setStatus(result.status()); - listOfMaps(body.get("activePunishments")).stream() + result.activePunishments().stream() .map(MinecraftPlayerProtoMapper::toSimplePunishment) .forEach(response::addActivePunishments); - listOfMaps(body.get("pendingNotifications")).stream() + result.pendingNotifications().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(response::addPendingNotifications); - list(body.get("pendingIpLookups")).stream() - .map(Objects::toString) - .forEach(response::addPendingIpLookups); + result.pendingIpLookups().forEach(response::addPendingIpLookups); - listOfMaps(body.get("pendingStatWipes")).stream() + result.pendingStatWipes().stream() .map(MinecraftPlayerProtoMapper::toPendingStatWipe) .forEach(response::addPendingStatWipes); return response.build(); } - static SimpleResponse toSimpleResponse(Map body) { + static SimpleResponse toSimpleResponse(boolean success) { return SimpleResponse.newBuilder() - .setSuccess(booleanValue(body.get("success"))) + .setSuccess(success) .build(); } - static OnlinePlayersResponse toOnlinePlayersResponse(Map body) { + static OnlinePlayersResponse toOnlinePlayersResponse(OnlinePlayersResult result) { OnlinePlayersResponse.Builder response = OnlinePlayersResponse.newBuilder() - .setStatus(intValue(body.get("status"))); + .setStatus(200); - listOfMaps(body.get("players")).stream() + result.players().stream() .map(MinecraftPlayerProtoMapper::toOnlinePlayer) .forEach(response::addPlayers); return response.build(); } - static PlayerProfileResponse toPlayerProfileResponse(Map body) { - Map profile = map(body.get("profile")); + static PlayerProfileResponse toPlayerProfileResponse(Map profile) { PlayerProfileResponse.Builder response = PlayerProfileResponse.newBuilder() - .setStatus(intValue(body.get("status"))) + .setStatus(200) .setProfile(toAccount(profile)); - setOptionalInt(response::setPunishmentCount, nestedOrTopLevel(profile, body, "punishmentCount")); - setOptionalInt(response::setNoteCount, nestedOrTopLevel(profile, body, "noteCount")); + setOptionalInt(response::setPunishmentCount, profile.get("punishmentCount")); + setOptionalInt(response::setNoteCount, profile.get("noteCount")); return response.build(); } - static PlayerGetResponse toPlayerGetResponse(Map body) { + static PlayerGetResponse toPlayerGetResponse(PlayerFetchResult.Found found) { return PlayerGetResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))) - .setPlayer(toAccount(map(body.get("player")))) + .setStatus(200) + .setMessage(stringValue(found.message())) + .setPlayer(toAccount(found.player())) .build(); } - static PlayerNameResponse toPlayerNameResponse(Map body) { + static PlayerNameResponse toPlayerNameResponse(PlayerFetchResult.Found found) { return PlayerNameResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))) - .setPlayer(toAccount(map(body.get("player")))) + .setStatus(200) + .setMessage(stringValue(found.message())) + .setPlayer(toAccount(found.player())) .build(); } - static PlayerLookupResponse toPlayerLookupResponse(Map body) { + static PlayerLookupResponse toPlayerLookupResponse(PlayerLookupResult.Found found) { return PlayerLookupResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))) - .setData(toPlayerLookupData(map(body.get("data")))) + .setStatus(200) + .setMessage(stringValue(found.message())) + .setData(toPlayerLookupData(found.data())) .build(); } - static PlayerNoteCreateResponse toPlayerNoteCreateResponse(Map body) { - PlayerNoteCreateResponse.Builder builder = PlayerNoteCreateResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setMessage(stringValue(body.get("message"))); - setBooleanIfPresent(builder, "setSuccess", body.get("success")); - return builder.build(); + static PlayerNoteCreateResponse toPlayerNoteCreateResponse(CreateNoteResult result) { + return switch (result) { + case CreateNoteResult.Created created -> PlayerNoteCreateResponse.newBuilder() + .setStatus(200) + .setMessage(stringValue(created.message())) + .setSuccess(true) + .build(); + case CreateNoteResult.NotFound notFound -> PlayerNoteCreateResponse.newBuilder() + .setStatus(404) + .setMessage(stringValue(notFound.message())) + .setSuccess(false) + .build(); + }; } - static LinkedAccountsResponse toLinkedAccountsResponse(Map body) { + static LinkedAccountsResponse toLinkedAccountsResponse(LinkedAccountsResult.Found found) { LinkedAccountsResponse.Builder response = LinkedAccountsResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setPage(intValue(body.get("page"))) - .setHasMore(booleanValue(body.get("hasMore"))); + .setStatus(200) + .setPage(intValue(found.page())) + .setHasMore(booleanValue(found.hasMore())); - setOptionalInt(response::setTotalCount, body.get("totalCount")); - listOfMaps(body.get("linkedAccounts")).stream() + setOptionalInt(response::setTotalCount, found.totalCount()); + found.linkedAccounts().stream() .map(MinecraftPlayerProtoMapper::toAccount) .forEach(response::addLinkedAccounts); return response.build(); } - static PaginatedPunishmentsResponse toPaginatedPunishmentsResponse(Map body) { + static PaginatedPunishmentsResponse toPaginatedPunishmentsResponse(PaginatedPunishmentsResult.Found found) { PaginatedPunishmentsResponse.Builder response = PaginatedPunishmentsResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setTotalCount(intValue(body.get("totalCount"))) - .setPage(intValue(body.get("page"))) - .setHasMore(booleanValue(body.get("hasMore"))); + .setStatus(200) + .setTotalCount(found.totalCount()) + .setPage(found.page()) + .setHasMore(found.hasMore()); - listOfMaps(body.get("punishments")).stream() + found.punishments().stream() .map(MinecraftPlayerProtoMapper::toPunishmentListEntry) .forEach(response::addPunishments); return response.build(); } - static PaginatedNotesResponse toPaginatedNotesResponse(Map body) { + static PaginatedNotesResponse toPaginatedNotesResponse(PaginatedNotesResult.Found found) { PaginatedNotesResponse.Builder response = PaginatedNotesResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setTotalCount(intValue(body.get("totalCount"))) - .setPage(intValue(body.get("page"))) - .setHasMore(booleanValue(body.get("hasMore"))); + .setStatus(200) + .setTotalCount(found.totalCount()) + .setPage(found.page()) + .setHasMore(found.hasMore()); - listOfMaps(body.get("notes")).stream() + found.notes().stream() .map(MinecraftPlayerProtoMapper::toNoteEntry) .forEach(response::addNotes); return response.build(); } - static ReportsResponse toReportsResponse(Map body) { + static ReportsResponse toReportsResponse(PlayerReportsResult result) { ReportsResponse.Builder response = ReportsResponse.newBuilder() - .setStatus(intValue(body.get("status"))); + .setStatus(200); - listOfMaps(body.get("reports")).stream() + result.reports().stream() .map(MinecraftPlayerProtoMapper::toReportEntry) .forEach(response::addReports); return response.build(); } - static PardonResponse toPardonResponse(Map body) { - return PardonResponse.newBuilder() - .setStatus(intValue(body.get("status"))) - .setSuccess(booleanValue(body.get("success"))) - .setPardonedCount(intValue(body.get("pardonedCount"))) - .setMessage(stringValue(body.get("message"))) - .build(); - } + static PardonResponse toPardonResponse(PardonResult result) { + return switch (result) { + case PardonResult.Pardoned pardoned -> PardonResponse.newBuilder() + .setStatus(200) + .setSuccess(pardoned.success()) + .setPardonedCount(pardoned.pardonedCount()) + .setMessage(stringValue(pardoned.message())) + .build(); + case PardonResult.PlayerNotFound notFound -> PardonResponse.newBuilder() + .setStatus(404) + .setSuccess(false) + .setPardonedCount(0) + .setMessage(stringValue(notFound.message())) + .build(); + }; + } + + public static SimplePunishment toSimplePunishment(SimplePunishmentView view) { + if (view == null) { + return SimplePunishment.getDefaultInstance(); + } - public static SimplePunishment toSimplePunishment(Map punishment) { SimplePunishment.Builder builder = SimplePunishment.newBuilder() - .setType(stringValue(punishment.get("type"))) - .setDescription(stringValue(punishment.get("description"))) - .setId(stringValue(punishment.get("id"))) - .setStarted(booleanValue(punishment.get("started"))) - .setOrdinal(intValue(punishment.get("ordinal"))); - - setOptionalString(builder::setCategory, punishment.get("category")); - setOptionalLong(builder::setExpiration, punishment.get("expiration")); - setOptionalString(builder::setIssuerName, punishment.get("issuerName")); - setOptionalLong(builder::setIssuedAt, punishment.get("issuedAt")); - setOptionalString(builder::setPlayerDescription, punishment.get("playerDescription")); + .setType(stringValue(view.type())) + .setDescription(stringValue(view.description())) + .setId(stringValue(view.id())) + .setStarted(view.started()) + .setOrdinal(view.ordinal()); + + setOptionalString(builder::setCategory, view.category()); + setOptionalLong(builder::setExpiration, view.expiration()); + setOptionalString(builder::setIssuerName, view.issuerName()); + setOptionalLong(builder::setIssuedAt, view.issuedAt()); + setOptionalString(builder::setPlayerDescription, view.playerDescription()); return builder.build(); } @@ -244,7 +272,7 @@ private static Account toAccount(Map account) { .map(MinecraftPlayerProtoMapper::toIpEntry) .forEach(builder::addIpAddresses); - listOfMaps(account.get("punishments")).stream() + listOf(account.get("punishments"), PunishmentView.class).stream() .map(MinecraftPlayerProtoMapper::toPunishmentResponse) .forEach(builder::addPunishments); @@ -273,7 +301,10 @@ private static NoteEntry toNoteEntry(Map note) { .setDate(dateAwareString(note.get("date"))) .setIssuerName(stringValue(note.get("issuerName"))) .setIssuerId(stringValue(note.get("issuerId"))); - setStringIfPresent(builder, "setId", note.get("id")); + Object id = note.get("id"); + if (id != null) { + builder.setId(Objects.toString(id)); + } return builder.build(); } @@ -295,63 +326,48 @@ private static IPEntry toIpEntry(Map ip) { return builder.build(); } - private static PunishmentResponse toPunishmentResponse(Map punishment) { + private static PunishmentResponse toPunishmentResponse(PunishmentView punishment) { PunishmentResponse.Builder builder = PunishmentResponse.newBuilder() - .setId(stringValue(punishment.get("id"))) - .setType(stringValue(punishment.get("type"))) - .setTypeOrdinal(intValue(punishment.get("typeOrdinal"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(longValue(punishment.get("issued"))) - .setIsAppealable(booleanValue(punishment.get("isAppealable"))) - .setActive(booleanValue(punishment.get("active"))); - - setOptionalLong(builder::setStarted, punishment.get("started")); - setOptionalString(builder::setReason, punishment.get("reason")); - setOptionalString(builder::setSeverity, punishment.get("severity")); - setOptionalString(builder::setStatus, punishment.get("status")); - setOptionalLong(builder::setExpires, punishment.get("expires")); - setOptionalString(builder::setPlayerUuid, punishment.get("playerUuid")); - setOptionalString(builder::setPlayerUsername, punishment.get("playerUsername")); - setOptionalBoolean(builder::setAltBlocking, punishment.get("altBlocking")); - setOptionalBoolean(builder::setStatWiping, punishment.get("statWiping")); - setOptionalString(builder::setEffectiveCategory, punishment.get("effectiveCategory")); - - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + .setId(stringValue(punishment.id())) + .setType(stringValue(punishment.type())) + .setTypeOrdinal(punishment.typeOrdinal()) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(longValue(punishment.issued())); + + setOptionalLong(builder::setStarted, punishment.started()); + setOptionalString(builder::setPlayerUuid, punishment.playerUuid()); + + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); return builder.build(); } - private static PunishmentListEntry toPunishmentListEntry(Map punishment) { + private static PunishmentListEntry toPunishmentListEntry(PunishmentView punishment) { PunishmentListEntry.Builder builder = PunishmentListEntry.newBuilder() - .setId(stringValue(punishment.get("id"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(longValue(punishment.get("issued"))) - .setType(stringValue(punishment.get("type"))); + .setId(stringValue(punishment.id())) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(longValue(punishment.issued())) + .setType(stringValue(punishment.type())); - setOptionalLong(builder::setStarted, punishment.get("started")); - setOptionalInt(builder::setTypeOrdinal, punishment.get("typeOrdinal")); + setOptionalLong(builder::setStarted, punishment.started()); + builder.setTypeOrdinal(punishment.typeOrdinal()); - listOfMaps(punishment.get("modifications")).stream() + punishment.modifications().stream() .map(MinecraftPlayerProtoMapper::toPunishmentModification) .forEach(builder::addModifications); - listOfMaps(punishment.get("notes")).stream() + punishment.notes().stream() .map(MinecraftPlayerProtoMapper::toPunishmentNote) .forEach(builder::addNotes); - listOfMaps(punishment.get("evidence")).stream() + punishment.evidence().stream() .map(MinecraftPlayerProtoMapper::toPunishmentEvidence) .forEach(builder::addEvidence); - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); - Object data = punishment.get("data"); - if (data instanceof Map dataMap) { - builder.setData(legacyStruct(stringObjectMap(dataMap))); + if (punishment.data() != null) { + builder.setData(legacyStruct(punishment.data())); } return builder.build(); @@ -493,29 +509,4 @@ private static PlayerLookupResponse.PlayerLookupRecentTicket toPlayerLookupRecen .setLastUpdated(dateAwareString(ticket.get("lastUpdated"))) .build(); } - - private static Object nestedOrTopLevel(Map nested, Map topLevel, String key) { - return nested.containsKey(key) ? nested.get(key) : topLevel.get(key); - } - - private static void setStringIfPresent(Object target, String methodName, Object value) { - if (value == null) { - return; - } - try { - target.getClass().getMethod(methodName, String.class).invoke(target, Objects.toString(value)); - } catch (NoSuchMethodException ignored) { - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to set protobuf field with " + methodName, e); - } - } - - private static void setBooleanIfPresent(Object target, String methodName, Object value) { - try { - target.getClass().getMethod(methodName, boolean.class).invoke(target, booleanValue(value)); - } catch (NoSuchMethodException ignored) { - } catch (ReflectiveOperationException e) { - throw new IllegalStateException("Failed to set protobuf field with " + methodName, e); - } - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java index f129129..56a39d8 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPlayerV3Controller.java @@ -7,6 +7,14 @@ import gg.modl.backend.infrastructure.rest.RESTMappingV3; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerProfileResult; import gg.modl.backend.player.service.MinecraftPlayerService; import gg.modl.backend.player.service.PlayerLookupService; import gg.modl.backend.server.data.Server; @@ -37,7 +45,6 @@ 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; @@ -68,7 +75,7 @@ public ResponseEntity login( ? ProtoMapperSupport.structToMap(request.getIpInfo()) : null; - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.login( + PlayerLoginResult result = minecraftPlayerService.login( server, UUID.fromString(request.getMinecraftUuid()), request.getUsername(), @@ -78,8 +85,8 @@ public ResponseEntity login( request.hasServerName() ? request.getServerName() : null ); - return ResponseEntity.status(response.status()) - .body(MinecraftPlayerProtoMapper.toPlayerLoginResponse(response.body())); + return ResponseEntity.status(result.status()) + .body(MinecraftPlayerProtoMapper.toPlayerLoginResponse(result)); } @PostMapping( @@ -92,13 +99,13 @@ public ResponseEntity disconnect( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.disconnect( + boolean success = minecraftPlayerService.disconnect( server, request.getMinecraftUuid(), request.getSessionDurationMs() - ); + ).success(); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(success)); } @PostMapping( @@ -111,13 +118,13 @@ public ResponseEntity updateServer( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.updateServer( + boolean success = minecraftPlayerService.updateServer( server, request.getMinecraftUuid(), request.getServerName() - ); + ).success(); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(success)); } @PostMapping( @@ -130,7 +137,7 @@ public ResponseEntity submitIpInfo( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.submitIpInfo( + boolean success = minecraftPlayerService.submitIpInfo( server, request.getMinecraftUuid(), request.getIp(), @@ -139,9 +146,9 @@ public ResponseEntity submitIpInfo( request.getAsn(), request.getProxy(), request.getHosting() - ); + ).success(); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toSimpleResponse(success)); } @GetMapping( @@ -150,8 +157,7 @@ public ResponseEntity submitIpInfo( ) public ResponseEntity getOnlinePlayers(HttpServletRequest httpRequest) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.getOnlinePlayers(server); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toOnlinePlayersResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toOnlinePlayersResponse(minecraftPlayerService.getOnlinePlayers(server))); } @GetMapping( @@ -165,9 +171,11 @@ public ResponseEntity getPlayerByUuid( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(response.body())); + return switch (playerLookupService.getPlayerByUuid(server, uuid, punishmentLimit, noteLimit)) { + case PlayerProfileResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(found.profile())); + case PlayerProfileResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -180,9 +188,12 @@ public ResponseEntity getPlayerByQuery( ) { requireNotBlank(minecraftUuid, "minecraftUuid parameter required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerGetResponse(response.body())); + return switch (playerLookupService.getPlayerByMinecraftUuid(server, minecraftUuid, queryMojang)) { + case PlayerFetchResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerGetResponse(found)); + case PlayerFetchResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + case PlayerFetchResult.InvalidRequest invalid -> throw new ValidationException(invalid.message()); + }; } @GetMapping( @@ -196,9 +207,12 @@ public ResponseEntity getPlayerByUsername( ) { requireNotBlank(username, "username parameter required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getPlayerByUsername(server, username, queryMojang); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerNameResponse(response.body())); + return switch (playerLookupService.getPlayerByUsername(server, username, queryMojang)) { + case PlayerFetchResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerNameResponse(found)); + case PlayerFetchResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + case PlayerFetchResult.InvalidRequest invalid -> throw new ValidationException(invalid.message()); + }; } @PostMapping( @@ -212,13 +226,11 @@ public ResponseEntity lookupPlayer( ) { requireNotBlank(request.getQuery(), "query is required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupPlayer( - server, - request.getQuery(), - shouldQueryMojang(request) - ); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerLookupResponse(response.body())); + return switch (playerLookupService.lookupPlayer(server, request.getQuery(), shouldQueryMojang(request))) { + case PlayerLookupResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerLookupResponse(found)); + case PlayerLookupResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @PostMapping( @@ -234,15 +246,17 @@ public ResponseEntity lookupProfile( ) { requireNotBlank(request.getQuery(), "query is required"); Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.lookupProfile( + return switch (playerLookupService.lookupProfile( server, request.getQuery(), shouldQueryMojang(request), punishmentLimit, noteLimit - ); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(response.body())); + )) { + case PlayerProfileResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPlayerProfileResponse(found.profile())); + case PlayerProfileResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @PostMapping( @@ -256,15 +270,15 @@ public ResponseEntity createPlayerNote( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.createNote( + CreateNoteResult result = minecraftPlayerService.createNote( server, uuid, request.getText(), request.hasIssuerName() ? request.getIssuerName() : null, request.hasIssuerId() ? request.getIssuerId() : null ); - return ResponseEntity.status(response.status()) - .body(MinecraftPlayerProtoMapper.toPlayerNoteCreateResponse(response.body())); + PlayerNoteCreateResponse response = MinecraftPlayerProtoMapper.toPlayerNoteCreateResponse(result); + return ResponseEntity.status(response.getStatus()).body(response); } @GetMapping( @@ -278,9 +292,11 @@ public ResponseEntity getLinkedAccounts( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = playerLookupService.getLinkedAccounts(server, uuid, page, limit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toLinkedAccountsResponse(response.body())); + return switch (playerLookupService.getLinkedAccounts(server, uuid, page, limit)) { + case LinkedAccountsResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toLinkedAccountsResponse(found)); + case LinkedAccountsResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -294,9 +310,11 @@ public ResponseEntity getPlayerPunishments( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedPunishmentsResponse(response.body())); + return switch (minecraftPlayerService.getPlayerPunishments(server, uuid, page, limit)) { + case PaginatedPunishmentsResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedPunishmentsResponse(found)); + case PaginatedPunishmentsResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -310,9 +328,11 @@ public ResponseEntity getPlayerNotes( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftPlayerService.ServiceResponse response = minecraftPlayerService.getPlayerNotes(server, uuid, page, limit); - requireSuccess(response); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedNotesResponse(response.body())); + return switch (minecraftPlayerService.getPlayerNotes(server, uuid, page, limit)) { + case PaginatedNotesResult.Found found -> + ResponseEntity.ok(MinecraftPlayerProtoMapper.toPaginatedNotesResponse(found)); + case PaginatedNotesResult.NotFound notFound -> throw new ResourceNotFoundException(notFound.message()); + }; } @GetMapping( @@ -324,8 +344,7 @@ public ResponseEntity getPlayerReports( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.getPlayerReports(server, uuid); - return ResponseEntity.ok(MinecraftPlayerProtoMapper.toReportsResponse(response)); + return ResponseEntity.ok(MinecraftPlayerProtoMapper.toReportsResponse(minecraftPlayerService.getPlayerReports(server, uuid))); } @PostMapping( @@ -338,48 +357,24 @@ public ResponseEntity pardonPlayer( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map response = minecraftPlayerService.pardonPlayer( + PardonResponse response = MinecraftPlayerProtoMapper.toPardonResponse(minecraftPlayerService.pardonPlayer( server, request.getPlayerName(), request.hasPunishmentType() ? request.getPunishmentType() : null, request.hasIssuerName() ? request.getIssuerName() : null, request.hasIssuerId() ? request.getIssuerId() : null, request.hasReason() ? request.getReason() : null - ); - HttpStatus status = intStatus(response) == 404 ? HttpStatus.NOT_FOUND : HttpStatus.OK; - return ResponseEntity.status(status) - .body(MinecraftPlayerProtoMapper.toPardonResponse(response)); + )); + return ResponseEntity.status(response.getStatus()).body(response); } private static boolean shouldQueryMojang(PlayerLookupRequest request) { return !request.hasQueryMojang() || request.getQueryMojang(); } - private static int intStatus(Map response) { - Object status = response.get("status"); - if (status instanceof Number number) { - return number.intValue(); - } - return 200; - } - private static void requireNotBlank(String value, String message) { if (value == null || value.isBlank()) { throw new ValidationException(message); } } - - private static void requireSuccess(MinecraftPlayerService.ServiceResponse response) { - if (response.status() == HttpStatus.OK) { - return; - } - Object message = response.body().get("message"); - if (response.status() == HttpStatus.NOT_FOUND) { - throw new ResourceNotFoundException(message == null ? "Not found" : message.toString()); - } - if (response.status() == HttpStatus.BAD_REQUEST) { - throw new ValidationException(message == null ? "Invalid request" : message.toString()); - } - throw new IllegalStateException(message == null ? "Unexpected player lookup response" : message.toString()); - } } 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 197cde7..ea3f2f2 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentController.java @@ -1,6 +1,5 @@ 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; @@ -10,11 +9,13 @@ 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.player.dto.response.PunishmentView; 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.player.dto.request.MinecraftCreatePunishmentRequest; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; @@ -77,7 +78,7 @@ public ResponseEntity> getPunishmentById( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map punishment = punishmentQueryService.getMinecraftPunishmentById(server, punishmentId).orElse(null); + PunishmentView punishment = punishmentQueryService.getMinecraftPunishmentById(server, punishmentId).orElse(null); if (punishment == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( "status", 404, @@ -384,21 +385,6 @@ public ResponseEntity> modifyPunishmentTickets( )); } - 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, diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java index e1ed65a..5cdd74e 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentProtoMapper.java @@ -1,97 +1,85 @@ package gg.modl.backend.player.controller; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.dateAwareString; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.list; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.listOfMaps; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.longValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalInt; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalLong; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringObjectMap; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; -import com.google.protobuf.Struct; import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import gg.modl.backend.player.dto.request.MinecraftCreatePunishmentRequest; +import gg.modl.backend.player.dto.response.PunishmentView; import gg.modl.proto.modl.v1.CreatePunishmentRequest; import gg.modl.proto.modl.v1.PunishmentDetailResponse; import gg.modl.proto.modl.v1.RecentPunishmentsResponse; -import java.util.Map; -import java.util.Objects; final class MinecraftPunishmentProtoMapper { private MinecraftPunishmentProtoMapper() { } - static PunishmentDetailResponse.PunishmentDetailEntry toPunishmentDetail(Map punishment) { + static PunishmentDetailResponse.PunishmentDetailEntry toPunishmentDetail(PunishmentView punishment) { PunishmentDetailResponse.PunishmentDetailEntry.Builder builder = PunishmentDetailResponse.PunishmentDetailEntry.newBuilder() - .setPlayerName(stringValue(punishment.get("playerName"))) - .setPlayerUuid(stringValue(punishment.get("playerUuid"))) - .setId(stringValue(punishment.get("id"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(dateAwareString(punishment.get("issued"))) - .setStarted(dateAwareString(punishment.get("started"))) - .setType(stringValue(punishment.get("type"))) - .setTypeOrdinal(intValue(punishment.get("typeOrdinal"))); + .setPlayerName(stringValue(punishment.playerName())) + .setPlayerUuid(stringValue(punishment.playerUuid())) + .setId(stringValue(punishment.id())) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(dateAwareString(punishment.issued())) + .setStarted(dateAwareString(punishment.started())) + .setType(stringValue(punishment.type())) + .setTypeOrdinal(punishment.typeOrdinal()); - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); - Map data = mapValue(punishment.get("data")); - if (data != null) { - builder.setData(ProtoMapperSupport.legacyStruct(data)); + if (punishment.data() != null) { + builder.setData(ProtoMapperSupport.legacyStruct(punishment.data())); } - listOfMaps(punishment.get("modifications")).stream() + punishment.modifications().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(builder::addModifications); - listOfMaps(punishment.get("notes")).stream() + punishment.notes().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(builder::addNotes); - listOfMaps(punishment.get("evidence")).stream() + punishment.evidence().stream() .map(ProtoMapperSupport::legacyStruct) .forEach(builder::addEvidence); return builder.build(); } - static RecentPunishmentsResponse.RecentPunishment toRecentPunishment(Map punishment) { + static RecentPunishmentsResponse.RecentPunishment toRecentPunishment(PunishmentView punishment) { RecentPunishmentsResponse.RecentPunishment.Builder builder = RecentPunishmentsResponse.RecentPunishment.newBuilder() - .setPlayerName(stringValue(punishment.get("playerName"))) - .setPlayerUuid(stringValue(punishment.get("playerUuid"))) - .setId(stringValue(punishment.get("id"))) - .setIssuerName(stringValue(punishment.get("issuerName"))) - .setIssued(longValue(punishment.get("issued"))) - .setType(stringValue(punishment.get("type"))); + .setPlayerName(stringValue(punishment.playerName())) + .setPlayerUuid(stringValue(punishment.playerUuid())) + .setId(stringValue(punishment.id())) + .setIssuerName(stringValue(punishment.issuerName())) + .setIssued(longValue(punishment.issued())) + .setType(stringValue(punishment.type())); - setOptionalLong(builder::setStarted, punishment.get("started")); - setOptionalInt(builder::setTypeOrdinal, punishment.get("typeOrdinal")); - listOfMaps(punishment.get("modifications")).stream() + setOptionalLong(builder::setStarted, punishment.started()); + builder.setTypeOrdinal(punishment.typeOrdinal()); + punishment.modifications().stream() .map(MinecraftPlayerProtoMapper::toPunishmentModification) .forEach(builder::addModifications); - listOfMaps(punishment.get("notes")).stream() + punishment.notes().stream() .map(MinecraftPlayerProtoMapper::toPunishmentNote) .forEach(builder::addNotes); - listOfMaps(punishment.get("evidence")).stream() + punishment.evidence().stream() .map(MinecraftPlayerProtoMapper::toPunishmentEvidence) .forEach(builder::addEvidence); - list(punishment.get("attachedTicketIds")).stream() - .map(Objects::toString) - .forEach(builder::addAttachedTicketIds); + punishment.attachedTicketIds().forEach(builder::addAttachedTicketIds); - Map data = mapValue(punishment.get("data")); - if (data != null) { - builder.setData(ProtoMapperSupport.legacyStruct(data)); + if (punishment.data() != null) { + builder.setData(ProtoMapperSupport.legacyStruct(punishment.data())); } return builder.build(); } - static MinecraftPunishmentController.MinecraftCreatePunishmentRequest toLegacyCreatePunishmentRequest( + static MinecraftCreatePunishmentRequest toLegacyCreatePunishmentRequest( CreatePunishmentRequest request ) { - return new MinecraftPunishmentController.MinecraftCreatePunishmentRequest( + return new MinecraftCreatePunishmentRequest( request.getTargetUuid(), request.hasIssuerName() ? request.getIssuerName() : null, request.hasIssuerId() ? request.getIssuerId() : null, @@ -105,14 +93,4 @@ static MinecraftPunishmentController.MinecraftCreatePunishmentRequest toLegacyCr request.hasStatus() ? request.getStatus() : null ); } - - private static Map mapValue(Object value) { - if (value instanceof Struct struct) { - return ProtoMapperSupport.structToMap(struct); - } - if (value instanceof Map map) { - return stringObjectMap(map); - } - return null; - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java index 12cf442..499178a 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftPunishmentV3Controller.java @@ -1,5 +1,7 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.emptyToNull; + import com.google.protobuf.Empty; import gg.modl.backend.infrastructure.proto.ProtobufMediaTypes; import gg.modl.backend.infrastructure.rest.RESTMappingV3; @@ -13,6 +15,7 @@ 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.player.dto.response.PunishmentView; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.AddPunishmentEvidenceRequest; import gg.modl.proto.modl.v1.AddPunishmentEvidenceResponse; @@ -42,7 +45,6 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import java.util.Map; import java.util.UUID; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -61,6 +63,8 @@ @RequiredArgsConstructor @Validated public class MinecraftPunishmentV3Controller { + private static final int RECENT_PUNISHMENTS_MAX_HOURS = 8760; + private final PunishmentLifecycleService punishmentLifecycleService; private final PunishmentMutationService punishmentMutationService; private final PunishmentEvidenceService punishmentEvidenceService; @@ -80,7 +84,7 @@ public ResponseEntity previewPunishment( @GetMapping(value = "/recent", produces = ProtobufMediaTypes.APPLICATION_X_PROTOBUF_VALUE) public ResponseEntity getRecentPunishments( - @RequestParam(defaultValue = "48") @Min(1) @Max(8760) int hours, + @RequestParam(defaultValue = "48") @Min(1) @Max(RECENT_PUNISHMENTS_MAX_HOURS) int hours, HttpServletRequest httpRequest ) { validateRecentHours(hours); @@ -100,7 +104,7 @@ public ResponseEntity getPunishmentById( HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - Map punishment = punishmentQueryService + PunishmentView punishment = punishmentQueryService .getMinecraftPunishmentById(server, punishmentId) .orElse(null); @@ -485,8 +489,8 @@ private ResponseEntity uploadTokenResponse( } private void validateRecentHours(int hours) { - if (hours < 1 || hours > 8760) { - throw new ValidationException("hours must be between 1 and 8760"); + if (hours < 1 || hours > RECENT_PUNISHMENTS_MAX_HOURS) { + throw new ValidationException("hours must be between 1 and " + RECENT_PUNISHMENTS_MAX_HOURS); } } @@ -531,10 +535,6 @@ private void validateCreatePunishmentData(CreatePunishmentRequest request) { } } - private String emptyToNull(String value) { - return value == null || value.isEmpty() ? null : value; - } - private ResponseEntity statWipeResponse( HttpStatus httpStatus, int bodyStatus, diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java index 86c5c56..90265f3 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftStartupController.java @@ -1,5 +1,6 @@ package gg.modl.backend.player.controller; +import gg.modl.backend.player.dto.request.StartupRequest; import gg.modl.backend.player.service.MinecraftStartupService; import gg.modl.backend.infrastructure.rest.RESTMappingV2; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -8,7 +9,6 @@ import jakarta.validation.Valid; import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,7 +18,6 @@ @RestController @RequestMapping(RESTMappingV2.PREFIX_MINECRAFT) @RequiredArgsConstructor -@Slf4j public class MinecraftStartupController { private final MinecraftStartupService minecraftStartupService; @@ -32,12 +31,4 @@ public ResponseEntity> startup( return ResponseEntity.ok(minecraftStartupService.handleStartup(server, request, clientIp)); } - public record StartupRequest( - String serverVersion, - String platformType, - String pluginVersion, - int maxPlayers, - String serverName - ) { - } } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java index 22179e1..43b0702 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncController.java @@ -1,6 +1,7 @@ package gg.modl.backend.player.controller; import gg.modl.backend.player.service.MinecraftSyncService; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.server.data.Server; @@ -15,9 +16,7 @@ import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.Size; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,43 +26,24 @@ @RestController @RequestMapping(RESTMappingV1.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j public class MinecraftSyncController { private final MinecraftSyncService minecraftSyncService; @PostMapping("/sync") - public ResponseEntity> sync( + public ResponseEntity sync( @RequestBody @Valid SyncRequest syncRequest, HttpServletRequest httpRequest ) { Server server = RequestUtil.getRequestServer(httpRequest); - MinecraftSyncService.ServerStatusInput serverStatusInput = syncRequest.serverStatus() != null - ? new MinecraftSyncService.ServerStatusInput( - syncRequest.serverStatus().onlinePlayerCount(), - syncRequest.serverStatus().maxPlayers(), - syncRequest.serverStatus().serverVersion(), - syncRequest.serverStatus().platformType(), - syncRequest.serverStatus().pluginVersion() - ) - : null; String clientIp = RequestUtil.getClientIp(httpRequest); return ResponseEntity.ok(minecraftSyncService.sync( server, syncRequest.lastSyncTimestamp(), - syncRequest.onlinePlayers() == null ? List.of() : syncRequest.onlinePlayers() - .stream() - .map(player -> new MinecraftSyncService.OnlinePlayerInput(player.uuid(), player.username(), player.ipAddress())) - .toList(), + SyncRequestMapper.toOnlinePlayers(syncRequest.onlinePlayers()), syncRequest.serverName(), - syncRequest.chatLogs() == null ? List.of() : syncRequest.chatLogs() - .stream() - .map(log -> new MinecraftSyncService.ChatLogInput(log.uuid(), log.username(), log.message(), log.timestamp(), log.server())) - .toList(), - syncRequest.commandLogs() == null ? List.of() : syncRequest.commandLogs() - .stream() - .map(log -> new MinecraftSyncService.CommandLogInput(log.uuid(), log.username(), log.command(), log.timestamp(), log.server())) - .toList(), - serverStatusInput, + SyncRequestMapper.toChatLogs(syncRequest.chatLogs()), + SyncRequestMapper.toCommandLogs(syncRequest.commandLogs()), + SyncRequestMapper.toServerStatus(syncRequest.serverStatus()), clientIp )); } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java index 841ff71..9507b50 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncProtoMapper.java @@ -20,24 +20,24 @@ static List toOnlinePlayers(SyncRequest static List toChatLogs(SyncRequest request) { return request.getChatLogsList().stream() - .map(log -> new MinecraftSyncService.ChatLogInput( - log.getUuid(), - log.getUsername(), - log.getMessage(), - log.getTimestamp(), - log.getServer() + .map(entry -> new MinecraftSyncService.ChatLogInput( + entry.getUuid(), + entry.getUsername(), + entry.getMessage(), + entry.getTimestamp(), + entry.getServer() )) .toList(); } static List toCommandLogs(SyncRequest request) { return request.getCommandLogsList().stream() - .map(log -> new MinecraftSyncService.CommandLogInput( - log.getUuid(), - log.getUsername(), - log.getCommand(), - log.getTimestamp(), - log.getServer() + .map(entry -> new MinecraftSyncService.CommandLogInput( + entry.getUuid(), + entry.getUsername(), + entry.getCommand(), + entry.getTimestamp(), + entry.getServer() )) .toList(); } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java index 99a773a..8178f3b 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV2Controller.java @@ -4,6 +4,7 @@ import gg.modl.backend.player.controller.MinecraftSyncController.CommandLogEntry; import gg.modl.backend.player.controller.MinecraftSyncController.OnlinePlayer; import gg.modl.backend.player.service.MinecraftSyncService; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.infrastructure.rest.RESTMappingV2; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; @@ -12,9 +13,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Size; 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.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -24,12 +23,11 @@ @RestController @RequestMapping(RESTMappingV2.MINECRAFT_PLAYERS) @RequiredArgsConstructor -@Slf4j public class MinecraftSyncV2Controller { private final MinecraftSyncService minecraftSyncService; @PostMapping("/sync") - public ResponseEntity> sync( + public ResponseEntity sync( @RequestBody @Valid V2SyncRequest syncRequest, HttpServletRequest httpRequest ) { @@ -38,19 +36,10 @@ public ResponseEntity> sync( return ResponseEntity.ok(minecraftSyncService.syncV2( server, syncRequest.lastSyncTimestamp(), - syncRequest.onlinePlayers() == null ? List.of() : syncRequest.onlinePlayers() - .stream() - .map(player -> new MinecraftSyncService.OnlinePlayerInput(player.uuid(), player.username(), player.ipAddress())) - .toList(), + SyncRequestMapper.toOnlinePlayers(syncRequest.onlinePlayers()), syncRequest.serverName(), - syncRequest.chatLogs() == null ? List.of() : syncRequest.chatLogs() - .stream() - .map(entry -> new MinecraftSyncService.ChatLogInput(entry.uuid(), entry.username(), entry.message(), entry.timestamp(), entry.server())) - .toList(), - syncRequest.commandLogs() == null ? List.of() : syncRequest.commandLogs() - .stream() - .map(entry -> new MinecraftSyncService.CommandLogInput(entry.uuid(), entry.username(), entry.command(), entry.timestamp(), entry.server())) - .toList(), + SyncRequestMapper.toChatLogs(syncRequest.chatLogs()), + SyncRequestMapper.toCommandLogs(syncRequest.commandLogs()), clientIp )); } diff --git a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java index 5ca8d08..4a20718 100644 --- a/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java +++ b/src/main/java/gg/modl/backend/player/controller/MinecraftSyncV3Controller.java @@ -7,6 +7,7 @@ import gg.modl.backend.infrastructure.validation.RequestValidationLimits; import gg.modl.backend.player.service.MinecraftSyncService; import gg.modl.backend.player.service.SyncProtoFactory; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.SimpleResponse; import gg.modl.proto.modl.v1.SyncRequest; @@ -14,7 +15,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import java.time.Instant; -import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -41,7 +41,7 @@ public ResponseEntity sync( Server server = RequestUtil.getRequestServer(httpRequest); requireLogBatchesWithinLimit(request); String clientIp = RequestUtil.getClientIp(httpRequest); - Map response = minecraftSyncService.sync( + SyncResult response = minecraftSyncService.sync( server, request.getLastSyncTimestamp(), MinecraftSyncProtoMapper.toOnlinePlayers(request), 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 72f3592..b3169d5 100644 --- a/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/PanelPlayerController.java @@ -1,21 +1,31 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.emptyToNull; + 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.dto.response.LinkedBanView; 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.replay.service.PlayerReplayListingService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; 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.AddPlayerIpRequest; +import gg.modl.proto.modl.v1.AddPlayerUsernameRequest; +import gg.modl.proto.modl.v1.CreatePlayerNoteRequest; +import gg.modl.proto.modl.v1.CreatePlayerRequest; +import gg.modl.proto.modl.v1.ModifyPunishmentTicketsRequest; import gg.modl.proto.modl.v1.PanelAddEvidenceRequest; import gg.modl.proto.modl.v1.PanelAddModificationRequest; import gg.modl.proto.modl.v1.PanelAddPunishmentNoteRequest; @@ -26,12 +36,12 @@ 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.PunishmentPreviewResponse; 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; @@ -47,6 +57,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_PLAYERS) +@RequiresPanelPermission(rule = PanelAccessRule.PLAYER_ACCESS) @RequiredArgsConstructor @Validated public class PanelPlayerController { @@ -58,7 +69,7 @@ public class PanelPlayerController { private final PunishmentEvidenceService punishmentEvidenceService; private final PunishmentMutationService punishmentMutationService; private final AccountLinkingService accountLinkingService; - private final ReplayService replayService; + private final PlayerReplayListingService playerReplayListingService; @GetMapping public ResponseEntity searchPlayers( @@ -82,7 +93,7 @@ public ResponseEntity getPlayer( @PostMapping public ResponseEntity createPlayer( - @RequestBody gg.modl.proto.modl.v1.CreatePlayerRequest createRequest, + @RequestBody CreatePlayerRequest createRequest, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); @@ -98,7 +109,7 @@ public ResponseEntity createPlayer( @PostMapping("/{uuid}/usernames") public ResponseEntity addUsername( @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.AddPlayerUsernameRequest addRequest, + @RequestBody AddPlayerUsernameRequest addRequest, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); @@ -109,7 +120,7 @@ public ResponseEntity addUsername( @PostMapping("/{uuid}/notes") public ResponseEntity addNote( @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.CreatePlayerNoteRequest addRequest, + @RequestBody CreatePlayerNoteRequest addRequest, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); @@ -117,8 +128,8 @@ public ResponseEntity addNote( server, UUID.fromString(uuid), addRequest.getText(), - PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerName()), - PanelPlayerProtoMapper.emptyToNull(addRequest.getIssuerId()) + emptyToNull(addRequest.getIssuerName()), + emptyToNull(addRequest.getIssuerId()) ); return ResponseEntity.ok(SUCCESS); } @@ -126,7 +137,7 @@ public ResponseEntity addNote( @PostMapping("/{uuid}/ips") public ResponseEntity addIp( @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, - @RequestBody gg.modl.proto.modl.v1.AddPlayerIpRequest addRequest, + @RequestBody AddPlayerIpRequest addRequest, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); @@ -135,6 +146,7 @@ public ResponseEntity addIp( } @PostMapping("/{uuid}/punishments") + @RequiresPanelPermission(rule = PanelAccessRule.PERMIT_ALL) public ResponseEntity createPunishment( @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, @RequestBody PanelCreatePunishmentRequest createRequest, @@ -184,7 +196,7 @@ public ResponseEntity getActivePunishments( } @GetMapping("/{uuid}/punishments/preview") - public ResponseEntity previewPunishment( + public ResponseEntity previewPunishment( @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, @RequestParam int typeOrdinal, HttpServletRequest request @@ -227,8 +239,8 @@ public ResponseEntity addPunishmentNote( UUID.fromString(uuid), punishmentId, noteRequest.getText(), - PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerName()), - PanelPlayerProtoMapper.emptyToNull(noteRequest.getIssuerId()) + emptyToNull(noteRequest.getIssuerName()), + emptyToNull(noteRequest.getIssuerId()) ); return ResponseEntity.ok(SUCCESS); } @@ -254,7 +266,7 @@ public ResponseEntity addEvidence( public ResponseEntity modifyPunishmentTickets( @PathVariable @Pattern(regexp = RegExpConstants.UUID) String uuid, @PathVariable String punishmentId, - @RequestBody gg.modl.proto.modl.v1.ModifyPunishmentTicketsRequest ticketRequest, + @RequestBody ModifyPunishmentTicketsRequest ticketRequest, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); @@ -288,7 +300,7 @@ public ResponseEntity getPlayerReplays( ) { Server server = RequestUtil.getRequestServer(request); return ResponseEntity.ok(PanelPlayerProtoMapper.toPlayerReplays( - replayService.listPlayerReplays(server, uuid))); + playerReplayListingService.listPlayerReplays(server, uuid))); } @GetMapping("/punishments/{punishmentId}/linked-bans") @@ -297,7 +309,7 @@ public ResponseEntity getLinkedBans( HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); - List> linkedBans = punishmentQueryService.getLinkedBansForParent(server, punishmentId); + List linkedBans = punishmentQueryService.getLinkedBansForParent(server, punishmentId); return ResponseEntity.ok(PanelPlayerProtoMapper.toLinkedBans(linkedBans)); } diff --git a/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java index 1a97fcf..51be25e 100644 --- a/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/PanelPlayerProtoMapper.java @@ -1,5 +1,8 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.emptyToNull; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; + import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.player.data.IPEntry; import gg.modl.backend.player.data.NoteEntry; @@ -17,6 +20,7 @@ import gg.modl.backend.player.dto.request.CreatePunishmentRequest; import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; import gg.modl.backend.player.dto.response.PunishmentResponse; +import gg.modl.backend.player.dto.response.LinkedBanView; import gg.modl.backend.replay.dto.PlayerReplayResponse; import gg.modl.proto.modl.v1.ActivePunishmentsResponse; import gg.modl.proto.modl.v1.BackendNoteEntry; @@ -32,7 +36,6 @@ import java.time.Instant; import java.util.Date; import java.util.List; -import java.util.Map; import java.util.Objects; final class PanelPlayerProtoMapper { @@ -149,14 +152,14 @@ static PanelLinkedAccountsResponse toLinkedAccounts(List return builder.build(); } - static PanelLinkedBansResponse toLinkedBans(List> linkedBans) { + static PanelLinkedBansResponse toLinkedBans(List linkedBans) { PanelLinkedBansResponse.Builder builder = PanelLinkedBansResponse.newBuilder(); - for (Map entry : linkedBans) { + for (LinkedBanView entry : linkedBans) { builder.addLinkedBans(PanelLinkedBanEntry.newBuilder() - .setPunishmentId(string(entry.get("punishmentId"))) - .setPlayerUuid(string(entry.get("playerUuid"))) - .setPlayerName(string(entry.get("playerName"))) - .setActive(Boolean.TRUE.equals(entry.get("active"))) + .setPunishmentId(string(entry.punishmentId())) + .setPlayerUuid(string(entry.playerUuid())) + .setPlayerName(string(entry.playerName())) + .setActive(entry.active()) .build()); } return builder.build(); @@ -394,12 +397,4 @@ private static String epochString(Date date) { private static String string(Object value) { return value == null ? "" : Objects.toString(value); } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } - - static String emptyToNull(String value) { - return value == null || value.isEmpty() ? null : value; - } } diff --git a/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java b/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java index 8f5bcf8..fffcd3b 100644 --- a/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java +++ b/src/main/java/gg/modl/backend/player/controller/PublicPlayerController.java @@ -2,7 +2,7 @@ import gg.modl.backend.player.external.CrafatarProxyService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; -import java.util.UUID; +import gg.modl.backend.infrastructure.util.UuidUtils; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -26,7 +26,7 @@ public ResponseEntity proxyAvatar( @RequestParam(defaultValue = "32") int size, @RequestParam(defaultValue = "true") boolean overlay ) { - String normalized = normalizeUuid(uuid); + String normalized = UuidUtils.dashed(uuid); if (normalized == null) { return ResponseEntity.badRequest().build(); } @@ -40,19 +40,4 @@ public ResponseEntity proxyAvatar( .contentType(MediaType.IMAGE_PNG) .body(avatar); } - - private static String normalizeUuid(String raw) { - if (raw == null) { - return null; - } - String s = raw.trim(); - if (s.matches("^[0-9a-fA-F]{32}$")) { - s = s.replaceFirst("(.{8})(.{4})(.{4})(.{4})(.{12})", "$1-$2-$3-$4-$5"); - } - try { - return UUID.fromString(s).toString(); - } catch (IllegalArgumentException e) { - return null; - } - } } diff --git a/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java b/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java index 45eb500..0f2d729 100644 --- a/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java +++ b/src/main/java/gg/modl/backend/player/controller/PunishmentPreviewProtoMapper.java @@ -1,9 +1,10 @@ package gg.modl.backend.player.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; + import gg.modl.backend.player.dto.response.PunishmentPreviewView; import gg.modl.backend.player.dto.response.PunishmentSeverityPreviewView; import gg.modl.proto.modl.v1.PunishmentPreviewResponse; -import java.util.function.Consumer; public final class PunishmentPreviewProtoMapper { private PunishmentPreviewProtoMapper() { @@ -21,11 +22,11 @@ public static PunishmentPreviewResponse toProto(PunishmentPreviewView preview) { .setSocialPoints(preview.getSocialPoints()) .setGameplayPoints(preview.getGameplayPoints()); - setIfNotNull(builder::setMessage, preview.getMessage()); - setIfNotNull(builder::setSocialStatus, preview.getSocialStatus()); - setIfNotNull(builder::setGameplayStatus, preview.getGameplayStatus()); - setIfNotNull(builder::setOffenderStatus, preview.getOffenderStatus()); - setIfNotNull(builder::setCategory, preview.getCategory()); + setOptionalString(builder::setMessage, preview.getMessage()); + setOptionalString(builder::setSocialStatus, preview.getSocialStatus()); + setOptionalString(builder::setGameplayStatus, preview.getGameplayStatus()); + setOptionalString(builder::setOffenderStatus, preview.getOffenderStatus()); + setOptionalString(builder::setCategory, preview.getCategory()); if (preview.getLenient() != null) { builder.setLenient(toProto(preview.getLenient())); @@ -52,18 +53,12 @@ private static PunishmentPreviewResponse.SeverityPreview toProto(PunishmentSever .setNewSocialPoints(preview.getNewSocialPoints()) .setNewGameplayPoints(preview.getNewGameplayPoints()); - setIfNotNull(builder::setSeverity, preview.getSeverity()); - setIfNotNull(builder::setDurationFormatted, preview.getDurationFormatted()); - setIfNotNull(builder::setPunishmentType, preview.getPunishmentType()); - setIfNotNull(builder::setNewSocialStatus, preview.getNewSocialStatus()); - setIfNotNull(builder::setNewGameplayStatus, preview.getNewGameplayStatus()); + setOptionalString(builder::setSeverity, preview.getSeverity()); + setOptionalString(builder::setDurationFormatted, preview.getDurationFormatted()); + setOptionalString(builder::setPunishmentType, preview.getPunishmentType()); + setOptionalString(builder::setNewSocialStatus, preview.getNewSocialStatus()); + setOptionalString(builder::setNewGameplayStatus, preview.getNewGameplayStatus()); return builder.build(); } - - private static void setIfNotNull(Consumer setter, String value) { - if (value != null) { - setter.accept(value); - } - } } diff --git a/src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java b/src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java new file mode 100644 index 0000000..6c67c82 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/controller/SyncRequestMapper.java @@ -0,0 +1,53 @@ +package gg.modl.backend.player.controller; + +import gg.modl.backend.player.controller.MinecraftSyncController.ChatLogEntry; +import gg.modl.backend.player.controller.MinecraftSyncController.CommandLogEntry; +import gg.modl.backend.player.controller.MinecraftSyncController.OnlinePlayer; +import gg.modl.backend.player.controller.MinecraftSyncController.ServerStatus; +import gg.modl.backend.player.service.MinecraftSyncService; +import java.util.List; + +final class SyncRequestMapper { + private SyncRequestMapper() { + } + + static List toOnlinePlayers(List players) { + if (players == null) { + return List.of(); + } + return players.stream() + .map(player -> new MinecraftSyncService.OnlinePlayerInput(player.uuid(), player.username(), player.ipAddress())) + .toList(); + } + + static List toChatLogs(List entries) { + if (entries == null) { + return List.of(); + } + return entries.stream() + .map(entry -> new MinecraftSyncService.ChatLogInput(entry.uuid(), entry.username(), entry.message(), entry.timestamp(), entry.server())) + .toList(); + } + + static List toCommandLogs(List entries) { + if (entries == null) { + return List.of(); + } + return entries.stream() + .map(entry -> new MinecraftSyncService.CommandLogInput(entry.uuid(), entry.username(), entry.command(), entry.timestamp(), entry.server())) + .toList(); + } + + static MinecraftSyncService.ServerStatusInput toServerStatus(ServerStatus serverStatus) { + if (serverStatus == null) { + return null; + } + return new MinecraftSyncService.ServerStatusInput( + serverStatus.onlinePlayerCount(), + serverStatus.maxPlayers(), + serverStatus.serverVersion(), + serverStatus.platformType(), + serverStatus.pluginVersion() + ); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/Player.java b/src/main/java/gg/modl/backend/player/data/Player.java index 15d798a..abebaab 100644 --- a/src/main/java/gg/modl/backend/player/data/Player.java +++ b/src/main/java/gg/modl/backend/player/data/Player.java @@ -10,10 +10,13 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; @@ -88,5 +91,15 @@ public class Player { @Field(name = "data") @Builder.Default + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) private Map data = new HashMap<>(); + + public PlayerDataView data() { + return PlayerDataView.ownedBy(data, map -> data = map); + } + + public void replaceData(Map replacement) { + this.data = replacement; + } } diff --git a/src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java b/src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java new file mode 100644 index 0000000..1b678e0 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/PlayerDataKeys.java @@ -0,0 +1,21 @@ +package gg.modl.backend.player.data; + +public final class PlayerDataKeys { + public static final String IS_ONLINE = "isOnline"; + public static final String LAST_LOGIN = "lastLogin"; + public static final String LAST_SERVER = "lastServer"; + public static final String TOTAL_PLAYTIME_SECONDS = "totalPlaytimeSeconds"; + public static final String FIRST_JOIN = "firstJoin"; + public static final String LAST_SKIN_HASH = "lastSkinHash"; + public static final String PENDING_NOTIFICATIONS = "pendingNotifications"; + public static final String LINKED_ACCOUNTS = "linkedAccounts"; + public static final String LAST_LINKED_UPDATE = "lastLinkedUpdate"; + public static final String COUNTRY = "country"; + public static final String REGION = "region"; + public static final String ASN = "asn"; + public static final String PROXY = "proxy"; + public static final String HOSTING = "hosting"; + + private PlayerDataKeys() { + } +} diff --git a/src/main/java/gg/modl/backend/player/data/PlayerDataView.java b/src/main/java/gg/modl/backend/player/data/PlayerDataView.java new file mode 100644 index 0000000..69dd55c --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/PlayerDataView.java @@ -0,0 +1,134 @@ +package gg.modl.backend.player.data; + +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; + +public final class PlayerDataView { + @Nullable + private Map data; + @Nullable + private final Consumer> installer; + + private PlayerDataView(@Nullable Map data, @Nullable Consumer> installer) { + this.data = data; + this.installer = installer; + } + + static PlayerDataView ownedBy(@Nullable Map data, Consumer> installer) { + return new PlayerDataView(data, installer); + } + + @Nullable + public Map asMap() { + return data; + } + + private Map mutable() { + if (data == null) { + data = new HashMap<>(); + if (installer != null) { + installer.accept(data); + } + } + return data; + } + + public boolean isOnline() { + return data != null && Boolean.TRUE.equals(data.get(PlayerDataKeys.IS_ONLINE)); + } + + public void setOnline(boolean online) { + mutable().put(PlayerDataKeys.IS_ONLINE, online); + } + + @Nullable + public Date lastLogin() { + return data != null && data.get(PlayerDataKeys.LAST_LOGIN) instanceof Date value ? value : null; + } + + public void setLastLogin(Date lastLogin) { + mutable().put(PlayerDataKeys.LAST_LOGIN, lastLogin); + } + + @Nullable + public String lastServer() { + return data != null && data.get(PlayerDataKeys.LAST_SERVER) instanceof String value ? value : null; + } + + public void setLastServer(String lastServer) { + mutable().put(PlayerDataKeys.LAST_SERVER, lastServer); + } + + @Nullable + public Number totalPlaytimeSeconds() { + return data != null && data.get(PlayerDataKeys.TOTAL_PLAYTIME_SECONDS) instanceof Number value ? value : null; + } + + @Nullable + public String lastSkinHash() { + return data != null && data.get(PlayerDataKeys.LAST_SKIN_HASH) instanceof String value ? value : null; + } + + public void setLastSkinHash(String lastSkinHash) { + mutable().put(PlayerDataKeys.LAST_SKIN_HASH, lastSkinHash); + } + + @Nullable + public Date firstJoin() { + return data != null && data.get(PlayerDataKeys.FIRST_JOIN) instanceof Date value ? value : null; + } + + public boolean hasFirstJoin() { + return data != null && data.containsKey(PlayerDataKeys.FIRST_JOIN); + } + + public void setFirstJoin(Date firstJoin) { + mutable().put(PlayerDataKeys.FIRST_JOIN, firstJoin); + } + + @Nullable + public Date lastLinkedUpdate() { + return data != null && data.get(PlayerDataKeys.LAST_LINKED_UPDATE) instanceof Date value ? value : null; + } + + public boolean hasLinkedAccounts() { + return data != null && data.containsKey(PlayerDataKeys.LINKED_ACCOUNTS); + } + + @Nullable + public Object linkedAccountsValue() { + return data != null ? data.get(PlayerDataKeys.LINKED_ACCOUNTS) : null; + } + + public List linkedAccountUuids() { + if (data == null || !(data.get(PlayerDataKeys.LINKED_ACCOUNTS) instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + + public List> pendingNotifications() { + if (data == null || !(data.get(PlayerDataKeys.PENDING_NOTIFICATIONS) instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(Map.class::isInstance) + .map(entry -> { + @SuppressWarnings("unchecked") + Map notification = (Map) entry; + return notification; + }) + .toList(); + } + + public void setPendingNotifications(List> pendingNotifications) { + mutable().put(PlayerDataKeys.PENDING_NOTIFICATIONS, pendingNotifications); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java b/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java index c84f313..08fa2ea 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/EnforcementCategory.java @@ -1,5 +1,5 @@ package gg.modl.backend.player.data.punishment; public enum EnforcementCategory { - BAN, MUTE; + BAN, MUTE } 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 ecffb4f..fd4b5fa 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 @@ -8,9 +8,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.data.mongodb.core.mapping.Field; @@ -69,5 +72,32 @@ public class Punishment { private List attachedTicketIds = new ArrayList<>(); @Nullable + @Getter(AccessLevel.NONE) + @Setter(AccessLevel.NONE) private Map data = new HashMap<>(); + + public PunishmentDataView data() { + return PunishmentDataView.ownedBy(data, map -> data = map); + } + + public void replaceData(Map replacement) { + this.data = replacement; + } + + public boolean isPardoned() { + return modifications.stream() + .anyMatch(modification -> PunishmentModificationType.isPardon(modification.type())); + } + + public boolean isUnstarted() { + if (!data().isUnstarted()) { + return false; + } + return modifications.stream() + .noneMatch(modification -> PunishmentModificationType.isPardon(modification.type())); + } + + public boolean hasUnstartedStatus() { + return data().isUnstarted(); + } } diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java index e01f605..80a004c 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentData.java @@ -1,78 +1,20 @@ package gg.modl.backend.player.data.punishment; -import java.util.Map; -import org.jetbrains.annotations.Nullable; - public final class PunishmentData { - private PunishmentData() {} - - @Nullable - public static String getStatus(@Nullable Map data) { - return data != null && data.get("status") instanceof String s ? s : null; - } - - @Nullable - public static Long getDuration(@Nullable Map data) { - if (data == null) return null; - Object v = data.get("duration"); - if (v instanceof Long l) return l; - if (v instanceof Number n) return n.longValue(); - return null; - } - - @Nullable - public static String getReason(@Nullable Map data) { - return data != null && data.get("reason") instanceof String s ? s : null; - } - - @Nullable - public static String getSeverity(@Nullable Map data) { - return data != null && data.get("severity") instanceof String s ? s : null; - } - - @Nullable - public static String getOffenseLevel(@Nullable Map data) { - return data != null && data.get("offenseLevel") instanceof String s ? s : null; - } - - @Nullable - public static String getEnforcementCategory(@Nullable Map data) { - return data != null && data.get("enforcementCategory") instanceof String s ? s : null; - } + public static final String STATUS = "status"; + public static final String DURATION = "duration"; + public static final String REASON = "reason"; + public static final String SEVERITY = "severity"; + public static final String OFFENSE_LEVEL = "offenseLevel"; + public static final String ENFORCEMENT_CATEGORY = "enforcementCategory"; + public static final String ALT_BLOCKING = "altBlocking"; + public static final String WIPE_AFTER_EXPIRY = "wipeAfterExpiry"; + public static final String STAT_WIPE_COMPLETED = "statWipeCompleted"; + public static final String PENDING_ACKNOWLEDGEMENT = "pendingAcknowledgement"; + public static final String LINKED_BAN_ID = "linkedBanId"; + public static final String LINKED_BAN_PARENT_UUID = "linkedBanParentUuid"; + public static final String BLOCKED_NAME = "blockedName"; + public static final String BLOCKED_SKIN = "blockedSkin"; - public static boolean isAltBlocking(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("altBlocking") : null); - } - - public static boolean isWipeAfterExpiry(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("wipeAfterExpiry") : null); - } - - public static boolean isStatWipeCompleted(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("statWipeCompleted") : null); - } - - public static boolean isPendingAcknowledgement(@Nullable Map data) { - return Boolean.TRUE.equals(data != null ? data.get("pendingAcknowledgement") : null); - } - - @Nullable - public static String getLinkedBanId(@Nullable Map data) { - return data != null && data.get("linkedBanId") instanceof String s ? s : null; - } - - @Nullable - public static String getLinkedBanParentUuid(@Nullable Map data) { - return data != null && data.get("linkedBanParentUuid") instanceof String s ? s : null; - } - - @Nullable - public static String getBlockedName(@Nullable Map data) { - return data != null && data.get("blockedName") instanceof String s ? s : null; - } - - @Nullable - public static String getBlockedSkin(@Nullable Map data) { - return data != null && data.get("blockedSkin") instanceof String s ? s : null; - } + private PunishmentData() {} } diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java new file mode 100644 index 0000000..25cf821 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentDataView.java @@ -0,0 +1,190 @@ +package gg.modl.backend.player.data.punishment; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; +import org.jetbrains.annotations.Nullable; + +public final class PunishmentDataView { + @Nullable + private Map data; + @Nullable + private final Consumer> installer; + + private PunishmentDataView(@Nullable Map data, @Nullable Consumer> installer) { + this.data = data; + this.installer = installer; + } + + static PunishmentDataView ownedBy(@Nullable Map data, Consumer> installer) { + return new PunishmentDataView(data, installer); + } + + public static PunishmentDataView ofMap(Map data) { + return new PunishmentDataView(data, null); + } + + @Nullable + public Map asMap() { + return data; + } + + private Map mutable() { + if (data == null) { + data = new HashMap<>(); + if (installer != null) { + installer.accept(data); + } + } + return data; + } + + @Nullable + public String status() { + return data != null && data.get(PunishmentData.STATUS) instanceof String value ? value : null; + } + + public boolean hasStatus() { + return data != null && data.containsKey(PunishmentData.STATUS); + } + + public boolean isUnstarted() { + return PunishmentStatus.UNSTARTED.equals(status()); + } + + public void setStatus(String status) { + mutable().put(PunishmentData.STATUS, status); + } + + public void removeStatus() { + if (data != null) { + data.remove(PunishmentData.STATUS); + } + } + + @Nullable + public Long duration() { + if (data == null) { + return null; + } + Object value = data.get(PunishmentData.DURATION); + if (value instanceof Long longValue) { + return longValue; + } + if (value instanceof Number number) { + return number.longValue(); + } + return null; + } + + public void setDuration(long duration) { + mutable().put(PunishmentData.DURATION, duration); + } + + @Nullable + public String reason() { + return data != null && data.get(PunishmentData.REASON) instanceof String value ? value : null; + } + + public void setReason(String reason) { + mutable().put(PunishmentData.REASON, reason); + } + + @Nullable + public String severity() { + return data != null && data.get(PunishmentData.SEVERITY) instanceof String value ? value : null; + } + + public void setSeverity(String severity) { + mutable().put(PunishmentData.SEVERITY, severity); + } + + @Nullable + public String offenseLevel() { + return data != null && data.get(PunishmentData.OFFENSE_LEVEL) instanceof String value ? value : null; + } + + public boolean hasOffenseLevel() { + return data != null && data.containsKey(PunishmentData.OFFENSE_LEVEL); + } + + public void setOffenseLevel(String offenseLevel) { + mutable().put(PunishmentData.OFFENSE_LEVEL, offenseLevel); + } + + @Nullable + public String enforcementCategory() { + return data != null && data.get(PunishmentData.ENFORCEMENT_CATEGORY) instanceof String value ? value : null; + } + + public void setEnforcementCategory(String enforcementCategory) { + mutable().put(PunishmentData.ENFORCEMENT_CATEGORY, enforcementCategory); + } + + public boolean altBlocking() { + return data != null && Boolean.TRUE.equals(data.get(PunishmentData.ALT_BLOCKING)); + } + + public void setAltBlocking(boolean altBlocking) { + mutable().put(PunishmentData.ALT_BLOCKING, altBlocking); + } + + public boolean wipeAfterExpiry() { + return data != null && Boolean.TRUE.equals(data.get(PunishmentData.WIPE_AFTER_EXPIRY)); + } + + public void setWipeAfterExpiry(boolean wipeAfterExpiry) { + mutable().put(PunishmentData.WIPE_AFTER_EXPIRY, wipeAfterExpiry); + } + + public boolean statWipeCompleted() { + return data != null && Boolean.TRUE.equals(data.get(PunishmentData.STAT_WIPE_COMPLETED)); + } + + public void setPendingAcknowledgement(boolean pendingAcknowledgement) { + mutable().put(PunishmentData.PENDING_ACKNOWLEDGEMENT, pendingAcknowledgement); + } + + public boolean removePendingAcknowledgement() { + return data != null && Boolean.TRUE.equals(data.remove(PunishmentData.PENDING_ACKNOWLEDGEMENT)); + } + + @Nullable + public String linkedBanId() { + return data != null && data.get(PunishmentData.LINKED_BAN_ID) instanceof String value ? value : null; + } + + public void setLinkedBanId(String linkedBanId) { + mutable().put(PunishmentData.LINKED_BAN_ID, linkedBanId); + } + + public void setLinkedBanParentUuid(String linkedBanParentUuid) { + mutable().put(PunishmentData.LINKED_BAN_PARENT_UUID, linkedBanParentUuid); + } + + @Nullable + public String blockedName() { + return data != null && data.get(PunishmentData.BLOCKED_NAME) instanceof String value ? value : null; + } + + public boolean hasBlockedName() { + return data != null && data.containsKey(PunishmentData.BLOCKED_NAME); + } + + public void setBlockedName(String blockedName) { + mutable().put(PunishmentData.BLOCKED_NAME, blockedName); + } + + @Nullable + public String blockedSkin() { + return data != null && data.get(PunishmentData.BLOCKED_SKIN) instanceof String value ? value : null; + } + + public boolean hasBlockedSkin() { + return data != null && data.containsKey(PunishmentData.BLOCKED_SKIN); + } + + public void setBlockedSkin(String blockedSkin) { + mutable().put(PunishmentData.BLOCKED_SKIN, blockedSkin); + } +} diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java index 24247f5..020645b 100644 --- a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentModificationType.java @@ -17,15 +17,18 @@ public boolean isPardon() { return this == MANUAL_PARDON || this == APPEAL_ACCEPT || this == SYSTEM_PARDON; } + public boolean isDurationChange() { + return this == MANUAL_DURATION_CHANGE || this == APPEAL_DURATION_CHANGE; + } + public static boolean isPardon(String type) { - return MANUAL_PARDON.name().equals(type) - || APPEAL_ACCEPT.name().equals(type) - || SYSTEM_PARDON.name().equals(type); + PunishmentModificationType value = fromName(type); + return value != null && value.isPardon(); } public static boolean isDurationChange(String type) { - return MANUAL_DURATION_CHANGE.name().equals(type) - || APPEAL_DURATION_CHANGE.name().equals(type); + PunishmentModificationType value = fromName(type); + return value != null && value.isDurationChange(); } public AppealWorkflowStatus appealOutcome() { diff --git a/src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java new file mode 100644 index 0000000..7d082e8 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/data/punishment/PunishmentToggleOption.java @@ -0,0 +1,44 @@ +package gg.modl.backend.player.data.punishment; + +import java.util.Locale; +import java.util.function.BiConsumer; +import org.jetbrains.annotations.Nullable; + +public enum PunishmentToggleOption { + ALT_BLOCKING(PunishmentData.ALT_BLOCKING, "alt-blocking", PunishmentDataView::setAltBlocking), + STAT_WIPE(PunishmentData.WIPE_AFTER_EXPIRY, "stat wipe", PunishmentDataView::setWipeAfterExpiry); + + private final String dataKey; + private final String displayName; + private final BiConsumer mutator; + + PunishmentToggleOption(String dataKey, String displayName, BiConsumer mutator) { + this.dataKey = dataKey; + this.displayName = displayName; + this.mutator = mutator; + } + + public String dataKey() { + return dataKey; + } + + public String displayName() { + return displayName; + } + + public void apply(PunishmentDataView data, boolean enabled) { + mutator.accept(data, enabled); + } + + @Nullable + public 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; + } + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java b/src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java deleted file mode 100644 index a6988a3..0000000 --- a/src/main/java/gg/modl/backend/player/dto/request/AddIpRequest.java +++ /dev/null @@ -1,9 +0,0 @@ -package gg.modl.backend.player.dto.request; - -import gg.modl.backend.infrastructure.validation.ValidIpAddress; -import jakarta.validation.constraints.NotBlank; - -public record AddIpRequest( - @NotBlank @ValidIpAddress String ipAddress -) { -} 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 deleted file mode 100644 index dbb226a..0000000 --- a/src/main/java/gg/modl/backend/player/dto/request/CreatePlayerRequest.java +++ /dev/null @@ -1,11 +0,0 @@ -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/request/CreateUploadTokenRequest.java b/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java index 32132f2..4d403c0 100644 --- a/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java +++ b/src/main/java/gg/modl/backend/player/dto/request/CreateUploadTokenRequest.java @@ -2,7 +2,7 @@ import gg.modl.backend.infrastructure.validation.RequestValidationLimits; import jakarta.validation.constraints.Size; -import org.springframework.lang.Nullable; +import org.jetbrains.annotations.Nullable; public record CreateUploadTokenRequest( @Nullable @Size(max = RequestValidationLimits.PLAYER_ISSUER_NAME_MAX_LENGTH) String issuerName, diff --git a/src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java b/src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java new file mode 100644 index 0000000..07ed7dc --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/request/MinecraftCreatePunishmentRequest.java @@ -0,0 +1,26 @@ +package gg.modl.backend.player.dto.request; + +import com.fasterxml.jackson.annotation.JsonProperty; +import gg.modl.backend.infrastructure.validation.RegExpConstants; +import gg.modl.backend.infrastructure.validation.RequestValidationLimits; +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; + +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 +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java b/src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java new file mode 100644 index 0000000..a1acc75 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/request/StartupRequest.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.request; + +public record StartupRequest( + String serverVersion, + String platformType, + String pluginVersion, + int maxPlayers, + String serverName +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java b/src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java new file mode 100644 index 0000000..15ea7e4 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/AcknowledgeResult.java @@ -0,0 +1,4 @@ +package gg.modl.backend.player.dto.response; + +public record AcknowledgeResult(String message) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java b/src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java new file mode 100644 index 0000000..d1a56fe --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/AppealEligibility.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.response; + +public sealed interface AppealEligibility permits AppealEligibility.Eligible, AppealEligibility.NotStarted { + + record Eligible(AppealInfoView info) implements AppealEligibility { + } + + record NotStarted(String message) implements AppealEligibility { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java b/src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java new file mode 100644 index 0000000..c40d35b --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/AppealInfoView.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Date; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record AppealInfoView( + String id, + String type, + @Nullable Date issued, + @Nullable Date expires, + boolean active, + boolean appealable, + String playerUuid, + @Nullable Map existingAppeal, + @Nullable Map appealForm +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java b/src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java new file mode 100644 index 0000000..975f54a --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/CreateNoteResult.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.response; + +public sealed interface CreateNoteResult permits CreateNoteResult.Created, CreateNoteResult.NotFound { + + record Created(String message) implements CreateNoteResult { + } + + record NotFound(String message) implements CreateNoteResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java new file mode 100644 index 0000000..b9b23c6 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/LinkedAccountsResult.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public sealed interface LinkedAccountsResult permits LinkedAccountsResult.Found, LinkedAccountsResult.NotFound { + + record Found( + List> linkedAccounts, + Integer totalCount, + Integer page, + Boolean hasMore + ) implements LinkedAccountsResult { + } + + record NotFound(String message) implements LinkedAccountsResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java b/src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java new file mode 100644 index 0000000..fbd9892 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/LinkedBanView.java @@ -0,0 +1,9 @@ +package gg.modl.backend.player.dto.response; + +public record LinkedBanView( + String punishmentId, + String playerUuid, + String playerName, + boolean active +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java b/src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java new file mode 100644 index 0000000..a83e034 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/OnlinePlayersResult.java @@ -0,0 +1,7 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public record OnlinePlayersResult(List> players) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java b/src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java new file mode 100644 index 0000000..0329b7a --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PaginatedNotesResult.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public sealed interface PaginatedNotesResult permits PaginatedNotesResult.Found, PaginatedNotesResult.NotFound { + + record Found( + List> notes, + int totalCount, + int page, + boolean hasMore + ) implements PaginatedNotesResult { + } + + record NotFound(String message) implements PaginatedNotesResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java b/src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java new file mode 100644 index 0000000..6255cdf --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PaginatedPunishmentsResult.java @@ -0,0 +1,18 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; + +public sealed interface PaginatedPunishmentsResult + permits PaginatedPunishmentsResult.Found, PaginatedPunishmentsResult.NotFound { + + record Found( + List punishments, + int totalCount, + int page, + boolean hasMore + ) implements PaginatedPunishmentsResult { + } + + record NotFound(String message) implements PaginatedPunishmentsResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PardonResult.java b/src/main/java/gg/modl/backend/player/dto/response/PardonResult.java new file mode 100644 index 0000000..6b736c7 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PardonResult.java @@ -0,0 +1,10 @@ +package gg.modl.backend.player.dto.response; + +public sealed interface PardonResult permits PardonResult.Pardoned, PardonResult.PlayerNotFound { + + record Pardoned(boolean success, int pardonedCount, String message) implements PardonResult { + } + + record PlayerNotFound(String message) implements PardonResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java new file mode 100644 index 0000000..0945f20 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerFetchResult.java @@ -0,0 +1,16 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Map; + +public sealed interface PlayerFetchResult + permits PlayerFetchResult.Found, PlayerFetchResult.NotFound, PlayerFetchResult.InvalidRequest { + + record Found(String message, Map player) implements PlayerFetchResult { + } + + record NotFound(String message) implements PlayerFetchResult { + } + + record InvalidRequest(String message) implements PlayerFetchResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java new file mode 100644 index 0000000..118e03a --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerLoginResult.java @@ -0,0 +1,12 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public record PlayerLoginResult( + int status, + List activePunishments, + List> pendingNotifications, + List pendingIpLookups, + List> pendingStatWipes +) {} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java new file mode 100644 index 0000000..0dad94c --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerLookupResult.java @@ -0,0 +1,12 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Map; + +public sealed interface PlayerLookupResult permits PlayerLookupResult.Found, PlayerLookupResult.NotFound { + + record Found(String message, Map data) implements PlayerLookupResult { + } + + record NotFound(String message) implements PlayerLookupResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java new file mode 100644 index 0000000..7be4b49 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerProfileResult.java @@ -0,0 +1,12 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Map; + +public sealed interface PlayerProfileResult permits PlayerProfileResult.Found, PlayerProfileResult.NotFound { + + record Found(Map profile) implements PlayerProfileResult { + } + + record NotFound(String message) implements PlayerProfileResult { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java b/src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java new file mode 100644 index 0000000..b683051 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PlayerReportsResult.java @@ -0,0 +1,7 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; + +public record PlayerReportsResult(List> reports) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java b/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java index 5429631..e407e1f 100644 --- a/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java +++ b/src/main/java/gg/modl/backend/player/dto/response/PunishmentPreviewResponse.java @@ -3,10 +3,6 @@ import lombok.Builder; import lombok.Data; -/** - * Response containing punishment preview calculations. - * Shows what the punishment would be before issuing. - */ @Data @Builder public class PunishmentPreviewResponse implements PunishmentPreviewView { @@ -14,24 +10,19 @@ public class PunishmentPreviewResponse implements PunishmentPreviewView { private final boolean success; private final String message; - // Player's current status private final String socialStatus; private final String gameplayStatus; private final int socialPoints; private final int gameplayPoints; - // Offender status - private final String offenderStatus; // "low", "medium", or "high" + private final String offenderStatus; - // Punishment preview for each severity private final SeverityPreview lenient; private final SeverityPreview regular; private final SeverityPreview aggravated; - // For single-severity punishments private final SeverityPreview singleSeverity; - // Punishment type info private final boolean singleSeverityPunishment; private final boolean permanentUntilUsernameChange; private final boolean permanentUntilSkinChange; @@ -50,14 +41,13 @@ public static PunishmentPreviewResponse error(String message) { @Data @Builder public static class SeverityPreview implements PunishmentSeverityPreviewView { - private final String severity; // "lenient", "regular", "aggravated" + private final String severity; private final int points; private final long durationMs; private final String durationFormatted; - private final String punishmentType; // "ban", "mute", "kick" + private final String punishmentType; private final boolean permanent; - // New status after this punishment private final String newSocialStatus; private final String newGameplayStatus; private final int newSocialPoints; diff --git a/src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java b/src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java new file mode 100644 index 0000000..b8e3d93 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/PunishmentView.java @@ -0,0 +1,27 @@ +package gg.modl.backend.player.dto.response; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record PunishmentView( + String id, + String issuerName, + @Nullable Date issued, + @Nullable Date started, + int typeOrdinal, + String type, + List> modifications, + List> notes, + List> evidence, + List attachedTicketIds, + Map data, + @Nullable String playerUuid, + @Nullable String playerName +) { + public PunishmentView withPlayer(@Nullable String playerUuid, @Nullable String playerName) { + return new PunishmentView(id, issuerName, issued, started, typeOrdinal, type, + modifications, notes, evidence, attachedTicketIds, data, playerUuid, playerName); + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java b/src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java new file mode 100644 index 0000000..7a2fcb7 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SimpleActionResult.java @@ -0,0 +1,4 @@ +package gg.modl.backend.player.dto.response; + +public record SimpleActionResult(boolean success) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java b/src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java new file mode 100644 index 0000000..dfa3535 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SimplePunishmentView.java @@ -0,0 +1,27 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import org.jetbrains.annotations.Nullable; + +public record SimplePunishmentView( + String id, + String type, + String category, + int typeOrdinal, + int ordinal, + boolean started, + @Nullable Long expiration, + String description, + String issuerName, + long issuedAt, + @Nullable String playerDescription, + List modifications +) { + public record Modification( + String type, + @Nullable Long timestamp, + long effectiveDuration, + String issuerName + ) { + } +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java b/src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java new file mode 100644 index 0000000..0edfe37 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SyncDataView.java @@ -0,0 +1,20 @@ +package gg.modl.backend.player.dto.response; + +import java.util.List; +import java.util.Map; +import org.jetbrains.annotations.Nullable; + +public record SyncDataView( + List pendingPunishments, + List recentlyStartedPunishments, + List recentlyModifiedPunishments, + List> playerNotifications, + List> staffNotifications, + List> activeStaffMembers, + List> pendingStatWipes, + @Nullable Long staffPermissionsUpdatedAt, + @Nullable Long punishmentTypesUpdatedAt, + @Nullable List> staff2faVerifications, + @Nullable Map migrationTask +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java b/src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java new file mode 100644 index 0000000..a7392bc --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SyncPunishmentEntry.java @@ -0,0 +1,8 @@ +package gg.modl.backend.player.dto.response; + +public record SyncPunishmentEntry( + String minecraftUuid, + String username, + SimplePunishmentView punishment +) { +} diff --git a/src/main/java/gg/modl/backend/player/dto/response/SyncResult.java b/src/main/java/gg/modl/backend/player/dto/response/SyncResult.java new file mode 100644 index 0000000..5dba1f9 --- /dev/null +++ b/src/main/java/gg/modl/backend/player/dto/response/SyncResult.java @@ -0,0 +1,7 @@ +package gg.modl.backend.player.dto.response; + +public record SyncResult( + String timestamp, + SyncDataView data +) { +} diff --git a/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java b/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java index b567d53..8989a52 100644 --- a/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java +++ b/src/main/java/gg/modl/backend/player/service/AccountLinkingService.java @@ -3,6 +3,7 @@ import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; import gg.modl.backend.player.data.IPEntry; 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.dto.response.LinkedAccountResponse; import gg.modl.backend.server.data.Server; @@ -12,7 +13,6 @@ import java.util.Date; import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -35,14 +35,8 @@ public List getLinkedAccounts(Server server, UUID playerU return new ArrayList<>(); } - Map data = player.getData(); - if (data == null || !data.containsKey("linkedAccounts")) { - return new ArrayList<>(); - } - - @SuppressWarnings("unchecked") - List linkedUuids = (List) data.get("linkedAccounts"); - if (linkedUuids == null || linkedUuids.isEmpty()) { + List linkedUuids = player.data().linkedAccountUuids(); + if (linkedUuids.isEmpty()) { return new ArrayList<>(); } @@ -81,22 +75,16 @@ private LinkedAccountResponse buildLinkedAccountResponse(Server server, Player p for (Punishment punishment : player.getPunishments()) { if (statusCalculator.isPunishmentActive(punishment)) { PunishmentType punishmentType = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.getTypeOrdinal()).orElse(null); - String category = statusCalculator.getEffectiveCategory(punishmentType, punishment.getData()); - if ("BAN".equals(category)) { + String category = statusCalculator.getEffectiveCategory(punishmentType, punishment.data()); + if (EnforcementCategory.BAN.name().equals(category)) { activeBans++; - } else if ("MUTE".equals(category)) { + } else if (EnforcementCategory.MUTE.name().equals(category)) { activeMutes++; } } } - Date lastLinkedUpdate = null; - if (player.getData() != null) { - Object lastUpdate = player.getData().get("lastLinkedUpdate"); - if (lastUpdate instanceof Date date) { - lastLinkedUpdate = date; - } - } + Date lastLinkedUpdate = player.data().lastLinkedUpdate(); return new LinkedAccountResponse( player.getMinecraftUuid().toString(), diff --git a/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java b/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java index 2b788ac..6488c19 100644 --- a/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java +++ b/src/main/java/gg/modl/backend/player/service/DuplicatePlayerMerger.java @@ -42,7 +42,7 @@ private void mergeInto(Player primary, Player other) { primary.setIpAddresses(mergeBy(primary.getIpAddresses(), other.getIpAddresses(), IPEntry::getIpAddress)); primary.setNotes(mergeBy(primary.getNotes(), other.getNotes(), NoteEntry::getId)); primary.setPunishments(mergeBy(primary.getPunishments(), other.getPunishments(), Punishment::getId)); - primary.setData(mergeData(primary.getData(), other.getData())); + primary.replaceData(mergeData(primary.data().asMap(), other.data().asMap())); } private List mergeBy(List primary, List other, Function identity) { diff --git a/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java b/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java index 7adb0d0..17ac1e8 100644 --- a/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java +++ b/src/main/java/gg/modl/backend/player/service/IssuerNameResolver.java @@ -1,8 +1,13 @@ package gg.modl.backend.player.service; 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.server.data.Server; import gg.modl.backend.staff.data.Staff; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import lombok.RequiredArgsConstructor; @@ -29,20 +34,32 @@ public String resolve(@Nullable String issuerId, @Nullable String issuerName, Se return "Console"; } - public String resolve(@Nullable String issuerId, @Nullable String issuerName, Map resolvedMap) { - if (issuerId != null && resolvedMap.containsKey(issuerId)) { - return resolvedMap.get(issuerId); - } - if (issuerName != null) { - return issuerName; - } - return issuerId != null ? "Unknown Staff" : "Console"; - } - public Map batchResolve(Set issuerIds, Server server) { if (issuerIds == null || issuerIds.isEmpty()) { return Map.of(); } return staffRepository.findUsernamesByIds(server, issuerIds); } + + public Map resolveForPunishments(Server server, Collection punishments) { + Set ids = new HashSet<>(); + for (Punishment punishment : punishments) { + ids.addAll(PunishmentQueryService.collectIssuerIds(punishment)); + } + return batchResolve(ids, server); + } + + public Map resolveForPlayers(Server server, Collection players) { + Set ids = new HashSet<>(); + for (Player player : players) { + List punishments = player.getPunishments(); + if (punishments == null) { + continue; + } + for (Punishment punishment : punishments) { + ids.addAll(PunishmentQueryService.collectIssuerIds(punishment)); + } + } + return batchResolve(ids, server); + } } diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java b/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java index b54bcf9..d493f2e 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftChatLogService.java @@ -4,9 +4,9 @@ import gg.modl.backend.database.mongo.repository.CommandLogMongoRepository; import gg.modl.backend.player.data.log.ChatLogDocument; import gg.modl.backend.player.data.log.CommandLogDocument; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.server.data.Server; import java.util.List; -import java.util.Locale; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,7 +24,7 @@ public void submitChatLogs(Server server, List entries) { chatLogRepository.insertAll(server, entries.stream() .map(entry -> ChatLogDocument.builder() - .uuid(normalizeUuid(entry.uuid())) + .uuid(UuidUtils.normalize(entry.uuid())) .username(entry.username()) .message(entry.message()) .timestamp(entry.timestamp()) @@ -40,7 +40,7 @@ public void submitCommandLogs(Server server, List entries) { commandLogRepository.insertAll(server, entries.stream() .map(entry -> CommandLogDocument.builder() - .uuid(normalizeUuid(entry.uuid())) + .uuid(UuidUtils.normalize(entry.uuid())) .username(entry.username()) .command(entry.command()) .timestamp(entry.timestamp()) @@ -50,7 +50,7 @@ public void submitCommandLogs(Server server, List entries) { } public List getChatLogs(Server server, String uuid, int limit) { - return chatLogRepository.findByUuidRecent(server, normalizeUuid(uuid), Math.min(limit, MAX_FETCH_LIMIT)) + return chatLogRepository.findByUuidRecent(server, UuidUtils.normalize(uuid), Math.min(limit, MAX_FETCH_LIMIT)) .stream() .map(entry -> new ChatLogEntryView( entry.getUuid(), @@ -63,7 +63,7 @@ public List getChatLogs(Server server, String uuid, int limit) } public List getCommandLogs(Server server, String uuid, int limit) { - return commandLogRepository.findByUuidRecent(server, normalizeUuid(uuid), Math.min(limit, MAX_FETCH_LIMIT)) + return commandLogRepository.findByUuidRecent(server, UuidUtils.normalize(uuid), Math.min(limit, MAX_FETCH_LIMIT)) .stream() .map(entry -> new CommandLogEntryView( entry.getUuid(), @@ -86,8 +86,4 @@ public record ChatLogEntryView(String uuid, String username, String message, lon public record CommandLogEntryView(String uuid, String username, String command, long timestamp, String server) { } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } \ No newline at end of file diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java b/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java index cabfcf0..9dbee1c 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftPlayerService.java @@ -1,7 +1,6 @@ package gg.modl.backend.player.service; 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.NoteEntry; @@ -9,32 +8,39 @@ import gg.modl.backend.player.data.UsernameEntry; import gg.modl.backend.player.data.punishment.Punishment; import gg.modl.backend.player.data.punishment.EnforcementCategory; -import gg.modl.backend.player.data.punishment.PunishmentModificationType; -import gg.modl.backend.player.data.punishment.PunishmentData; -import gg.modl.backend.player.data.punishment.PunishmentStatus; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.dto.request.AcknowledgeNotificationsRequest; +import gg.modl.backend.player.dto.response.AcknowledgeResult; +import gg.modl.backend.player.dto.response.CreateNoteResult; +import gg.modl.backend.player.dto.response.OnlinePlayersResult; +import gg.modl.backend.player.dto.response.PaginatedNotesResult; +import gg.modl.backend.player.dto.response.PaginatedPunishmentsResult; +import gg.modl.backend.player.dto.response.PardonResult; +import gg.modl.backend.player.dto.response.PlayerLoginResult; +import gg.modl.backend.player.dto.response.PlayerReportsResult; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.player.dto.response.SimpleActionResult; +import gg.modl.backend.player.dto.response.SimplePunishmentView; 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.infrastructure.util.PaginationHelper; -import gg.modl.backend.player.service.PlayerDataUtils; import java.util.ArrayList; -import java.util.Collections; import java.util.Date; -import java.util.HashSet; import java.util.LinkedHashMap; 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 gg.modl.backend.infrastructure.util.IdGenerator; -import org.springframework.http.HttpStatus; +import gg.modl.backend.infrastructure.util.UuidUtils; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class MinecraftPlayerService { private final PlayerService playerService; private final PlayerMongoRepository playerRepository; @@ -44,31 +50,8 @@ public class MinecraftPlayerService { private final PunishmentLifecycleService punishmentLifecycleService; private final AccountLinkingService accountLinkingService; private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; - - public MinecraftPlayerService( - PlayerService playerService, - PlayerMongoRepository playerRepository, - TicketMongoRepository ticketRepository, - PlayerStatusCalculator statusCalculator, - PunishmentTypeService punishmentTypeService, - PunishmentLifecycleService punishmentLifecycleService, - AccountLinkingService accountLinkingService, - IssuerNameResolver issuerNameResolver, - StaffMongoRepository staffRepository - ) { - this.playerService = playerService; - this.playerRepository = playerRepository; - this.ticketRepository = ticketRepository; - this.statusCalculator = statusCalculator; - this.punishmentTypeService = punishmentTypeService; - this.punishmentLifecycleService = punishmentLifecycleService; - this.accountLinkingService = accountLinkingService; - this.issuerNameResolver = issuerNameResolver; - this.staffRepository = staffRepository; - } - public ServiceResponse login( + public PlayerLoginResult login( Server server, UUID playerUuid, String username, @@ -87,8 +70,8 @@ public ServiceResponse login( player = promotePunishments(server, player, playerUuid, username, skinHash); List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuersForPlayer(server, player); - List> activePunishments = collectActivePunishments(player, punishmentTypes, resolvedIssuers); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, List.of(player)); + List activePunishments = collectActivePunishments(player, punishmentTypes, resolvedIssuers); return buildLoginResponse(server, player, playerUuid, ip, username, activePunishments, isNewIp); } @@ -104,12 +87,12 @@ private Player promotePunishments(Server server, Player player, UUID playerUuid, return player; } - private List> collectActivePunishments( + private List collectActivePunishments( Player player, List punishmentTypes, Map resolvedIssuers ) { - List> activePunishments = new ArrayList<>(); + List activePunishments = new ArrayList<>(); Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(punishmentTypes); for (Punishment punishment : player.getPunishments()) { @@ -135,22 +118,16 @@ private List> collectActivePunishments( return deduplicateActivePunishments(activePunishments); } - private List> deduplicateActivePunishments(List> punishments) { - Map> oldestByCategory = new LinkedHashMap<>(); - List> result = new ArrayList<>(); + private List deduplicateActivePunishments(List punishments) { + Map oldestByCategory = new LinkedHashMap<>(); + List result = new ArrayList<>(); - for (Map punishment : punishments) { - String category = (String) punishment.get("category"); + for (SimplePunishmentView punishment : punishments) { + String category = punishment.category(); if (EnforcementCategory.BAN.name().equals(category) || EnforcementCategory.MUTE.name().equals(category)) { - Map existing = oldestByCategory.get(category); - if (existing == null) { + SimplePunishmentView existing = oldestByCategory.get(category); + if (existing == null || punishment.issuedAt() < existing.issuedAt()) { oldestByCategory.put(category, punishment); - } else { - long existingIssued = existing.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - long currentIssued = punishment.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - if (currentIssued < existingIssued) { - oldestByCategory.put(category, punishment); - } } } else { result.add(punishment); @@ -161,16 +138,16 @@ private List> deduplicateActivePunishments(List> activePunishments, + List activePunishments, boolean isNewIp ) { - List> pendingNotifications = extractPendingNotifications(player); + List> pendingNotifications = player.data().pendingNotifications(); List pendingIpLookups = new ArrayList<>(); if (ip != null && isNewIp) { @@ -184,10 +161,9 @@ private ServiceResponse buildLoginResponse( List> pendingStatWipes = new ArrayList<>(); for (Punishment punishment : player.getPunishments()) { - Map data = punishment.getData(); - if (data == null - || !PunishmentData.isWipeAfterExpiry(data) - || PunishmentData.isStatWipeCompleted(data) + PunishmentDataView data = punishment.data(); + if (!data.wipeAfterExpiry() + || data.statWipeCompleted() || !statusCalculator.isPunishmentNaturallyExpired(punishment)) { continue; } @@ -200,38 +176,13 @@ private ServiceResponse buildLoginResponse( } boolean isNewPlayer = player.getUsernames().size() == 1; - Map responseBody = new LinkedHashMap<>(); - responseBody.put("status", isNewPlayer ? 201 : 200); - responseBody.put("activePunishments", activePunishments); - responseBody.put("pendingNotifications", pendingNotifications); - if (!pendingIpLookups.isEmpty()) { - responseBody.put("pendingIpLookups", pendingIpLookups); - } - if (!pendingStatWipes.isEmpty()) { - responseBody.put("pendingStatWipes", pendingStatWipes); - } - - return new ServiceResponse(isNewPlayer ? HttpStatus.CREATED : HttpStatus.OK, responseBody); - } - - private List> extractPendingNotifications(Player player) { - if (player.getData() == null) { - return List.of(); - } - - Object rawPending = player.getData().getOrDefault("pendingNotifications", Collections.emptyList()); - if (!(rawPending instanceof List list)) { - return List.of(); - } - - return list.stream() - .filter(entry -> entry instanceof Map) - .map(entry -> { - @SuppressWarnings("unchecked") - Map notification = (Map) entry; - return notification; - }) - .toList(); + return new PlayerLoginResult( + isNewPlayer ? 201 : 200, + activePunishments, + pendingNotifications, + pendingIpLookups, + pendingStatWipes + ); } private List safeUsernames(Player player) { @@ -239,38 +190,26 @@ private List safeUsernames(Player player) { } private Optional findPlayerByUuid(Server server, String uuid) { - return playerRepository.findByMinecraftUuid(server, normalizeUuid(uuid)); + return playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(uuid)); } - private Map resolveIssuersForPlayer(Server server, Player player) { - Set ids = new HashSet<>(); - for (Punishment p : player.getPunishments()) { - ids.addAll(PunishmentQueryService.collectIssuerIds(p)); - } - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - - public Map disconnect(Server server, String minecraftUuid, long sessionDurationMs) { - playerRepository.markDisconnected(server, normalizeUuid(minecraftUuid), sessionDurationMs); - return Map.of("status", 200, "success", true); + public SimpleActionResult disconnect(Server server, String minecraftUuid, long sessionDurationMs) { + playerRepository.markDisconnected(server, UuidUtils.normalize(minecraftUuid), sessionDurationMs); + return new SimpleActionResult(true); } - public Map updateServer(Server server, String minecraftUuid, String serverName) { - playerRepository.updateLastServer(server, normalizeUuid(minecraftUuid), serverName); - return Map.of("status", 200, "success", true); + public SimpleActionResult updateServer(Server server, String minecraftUuid, String serverName) { + playerRepository.updateLastServer(server, UuidUtils.normalize(minecraftUuid), serverName); + return new SimpleActionResult(true); } - public Map getOnlinePlayers(Server server) { + public OnlinePlayersResult getOnlinePlayers(Server server) { List> players = playerRepository.findOnlinePlayers(server, 500) .stream() .map(player -> { - Object lastLoginObj = player.getData() != null ? player.getData().get("lastLogin") : null; - Date joinedAt = lastLoginObj instanceof Date date ? date : null; - Object playtimeObj = player.getData() != null ? player.getData().get("totalPlaytimeSeconds") : null; - long totalPlaytimeMs = playtimeObj instanceof Number number ? number.longValue() * 1000 : 0L; + Date joinedAt = player.data().lastLogin(); + Number playtime = player.data().totalPlaytimeSeconds(); + long totalPlaytimeMs = playtime != null ? playtime.longValue() * 1000 : 0L; Map entry = new LinkedHashMap<>(); entry.put("uuid", player.getMinecraftUuid().toString()); @@ -281,13 +220,13 @@ public Map getOnlinePlayers(Server server) { }) .toList(); - return Map.of("status", 200, "players", players); + return new OnlinePlayersResult(players); } - public ServiceResponse createNote(Server server, String uuid, String text, String issuerName, String issuerId) { + public CreateNoteResult createNote(Server server, String uuid, String text, String issuerName, String issuerId) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new CreateNoteResult.NotFound("Player not found"); } ensurePlayerNotes(player).add(NoteEntry.builder() @@ -299,11 +238,7 @@ public ServiceResponse createNote(Server server, String uuid, String text, Strin .build()); playerRepository.replaceNotes(server, player); - return ok(Map.of( - "status", 200, - "success", true, - "message", "Note added" - )); + return new CreateNoteResult.Created("Note added"); } private List ensurePlayerNotes(Player player) { @@ -313,11 +248,11 @@ private List ensurePlayerNotes(Player player) { return player.getNotes(); } - public ServiceResponse acknowledgeNotifications(Server server, AcknowledgeNotificationsRequest request) { + public AcknowledgeResult acknowledgeNotifications(Server server, AcknowledgeNotificationsRequest request) { return acknowledgeNotifications(server, request.playerUuid(), request.notificationIds(), request.acknowledgedAt()); } - public ServiceResponse acknowledgeNotifications( + public AcknowledgeResult acknowledgeNotifications( Server server, String playerUuid, List notificationIds, @@ -325,14 +260,10 @@ public ServiceResponse acknowledgeNotifications( ) { Player player = findPlayerByUuid(server, playerUuid).orElse(null); if (player == null) { - return ok(Map.of( - "status", 200, - "success", true, - "message", "No player found, nothing to acknowledge" - )); + return new AcknowledgeResult("No player found, nothing to acknowledge"); } - List> pendingNotifications = extractPendingNotifications(player); + List> pendingNotifications = player.data().pendingNotifications(); List> remainingNotifications = pendingNotifications.stream() .filter(notification -> { Object notificationId = notification.get("id"); @@ -340,53 +271,36 @@ public ServiceResponse acknowledgeNotifications( }) .toList(); - ensurePlayerData(player).put("pendingNotifications", remainingNotifications); + player.data().setPendingNotifications(remainingNotifications); playerRepository.replacePendingNotifications(server, player, remainingNotifications); - return ok(Map.of( - "status", 200, - "success", true, - "message", "Acknowledged " + (pendingNotifications.size() - remainingNotifications.size()) + " notification(s)" - )); - } - - private Map ensurePlayerData(Player player) { - if (player.getData() == null) { - player.setData(new LinkedHashMap<>()); - } - return player.getData(); + return new AcknowledgeResult( + "Acknowledged " + (pendingNotifications.size() - remainingNotifications.size()) + " notification(s)"); } - public ServiceResponse getPlayerPunishments(Server server, String uuid, int page, int limit) { + public PaginatedPunishmentsResult getPlayerPunishments(Server server, String uuid, int page, int limit) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new PaginatedPunishmentsResult.NotFound("Player not found"); } List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuersForPlayer(server, player); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, List.of(player)); - List> allPunishments = player.getPunishments() + List allPunishments = player.getPunishments() .stream() .sorted((a, b) -> b.getIssued().compareTo(a.getIssued())) - .map(p -> PunishmentMapper.toPunishmentMap(p, punishmentTypes, resolvedIssuers)) + .map(p -> PunishmentMapper.toPunishmentView(p, punishmentTypes, resolvedIssuers)) .toList(); - PaginationHelper.PageResult> result = PaginationHelper.paginate(allPunishments, page, limit); - - Map response = new LinkedHashMap<>(); - response.put("status", 200); - response.put("punishments", result.items()); - response.put("totalCount", result.totalCount()); - response.put("page", result.page()); - response.put("hasMore", result.hasMore()); - return ok(response); + PaginationHelper.PageResult result = PaginationHelper.paginate(allPunishments, page, limit); + return new PaginatedPunishmentsResult.Found(result.items(), result.totalCount(), result.page(), result.hasMore()); } - public ServiceResponse getPlayerNotes(Server server, String uuid, int page, int limit) { + public PaginatedNotesResult getPlayerNotes(Server server, String uuid, int page, int limit) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new PaginatedNotesResult.NotFound("Player not found"); } List> allNotes = player.getNotes() @@ -404,18 +318,11 @@ public ServiceResponse getPlayerNotes(Server server, String uuid, int page, int .toList(); PaginationHelper.PageResult> result = PaginationHelper.paginate(allNotes, page, limit); - - Map response = new LinkedHashMap<>(); - response.put("status", 200); - response.put("notes", result.items()); - response.put("totalCount", result.totalCount()); - response.put("page", result.page()); - response.put("hasMore", result.hasMore()); - return ok(response); + return new PaginatedNotesResult.Found(result.items(), result.totalCount(), result.page(), result.hasMore()); } - public Map getPlayerReports(Server server, String uuid) { - List> reports = ticketRepository.findReportedPlayerTickets(server, normalizeUuid(uuid), 50) + public PlayerReportsResult getPlayerReports(Server server, String uuid) { + List> reports = ticketRepository.findReportedPlayerTickets(server, UuidUtils.normalize(uuid), 50) .stream() .map(ticket -> { Map report = new LinkedHashMap<>(); @@ -431,10 +338,10 @@ public Map getPlayerReports(Server server, String uuid) { }) .toList(); - return Map.of("status", 200, "reports", reports); + return new PlayerReportsResult(reports); } - public Map submitIpInfo( + public SimpleActionResult submitIpInfo( Server server, String minecraftUuid, String ip, @@ -444,39 +351,38 @@ public Map submitIpInfo( boolean proxy, boolean hosting ) { - playerService.updateIpGeoData(server, normalizeUuid(minecraftUuid), ip, Map.of( + playerService.updateIpGeoData(server, UuidUtils.normalize(minecraftUuid), ip, Map.of( "country", country != null ? country : "", "region", region != null ? region : "", "asn", asn != null ? asn : "", "proxy", proxy, "hosting", hosting )); - return Map.of("status", 200, "success", true); + return new SimpleActionResult(true); } - public Map pardonPlayer(Server server, String playerName, String punishmentType, String issuerName, String issuerId, String reason) { + public PardonResult pardonPlayer(Server server, String playerName, String punishmentType, String issuerName, String issuerId, String reason) { Player player = playerService.findBestByUsername(server, playerName).orElse(null); if (player == null) { - return Map.of("status", 404, "message", "Player not found"); + return new PardonResult.PlayerNotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); List targets = new ArrayList<>(); for (Punishment punishment : player.getPunishments()) { - if (isAlreadyPardoned(punishment)) { + if (punishment.isPardoned()) { continue; } boolean isActive = statusCalculator.isPunishmentActive(punishment); - Map punishmentData = punishment.getData(); - boolean isUnstarted = PunishmentStatus.UNSTARTED.equals(PunishmentData.getStatus(punishmentData)); + boolean isUnstarted = punishment.hasUnstartedStatus(); boolean shouldPardon; if (punishmentType == null) { shouldPardon = isActive || isUnstarted; } else { - String requestedType = punishmentType.toLowerCase(); + String requestedType = punishmentType.toLowerCase(Locale.ROOT); String effectiveCategory = statusCalculator.getEffectiveCategory(punishment, types); shouldPardon = ("ban".equals(requestedType) && EnforcementCategory.BAN.name().equals(effectiveCategory) && (isActive || isUnstarted)) || ("mute".equals(requestedType) && EnforcementCategory.MUTE.name().equals(effectiveCategory) && (isActive || isUnstarted)); @@ -491,35 +397,12 @@ public Map pardonPlayer(Server server, String playerName, String ? 0 : punishmentLifecycleService.pardonPunishments(server, player, targets, issuerName, issuerId, reason); - return Map.of( - "status", 200, - "success", pardoned > 0, - "pardonedCount", pardoned, - "message", pardoned > 0 - ? "Pardoned " + pardoned + " punishment(s)" - : "No punishments found to pardon" + return new PardonResult.Pardoned( + pardoned > 0, + pardoned, + pardoned > 0 + ? "Pardoned " + pardoned + " punishment(s)" + : "No punishments found to pardon" ); } - - private boolean isAlreadyPardoned(Punishment punishment) { - return punishment.getModifications() - .stream() - .anyMatch(modification -> - PunishmentModificationType.isPardon(modification.type())); - } - - private ServiceResponse ok(Map body) { - return new ServiceResponse(HttpStatus.OK, body); - } - - private ServiceResponse notFound(String message) { - return new ServiceResponse(HttpStatus.NOT_FOUND, Map.of("status", 404, "message", message)); - } - - public record ServiceResponse(HttpStatus status, Map body) { - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java b/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java index 5cd43c1..8bc4adf 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftStartupService.java @@ -1,9 +1,9 @@ package gg.modl.backend.player.service; -import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.service.PanelDomainResolver; import gg.modl.backend.database.mongo.repository.ServerInstanceSnapshotMongoRepository; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; -import gg.modl.backend.player.controller.MinecraftStartupController.StartupRequest; +import gg.modl.backend.database.mongo.repository.ServerActivityRepository; +import gg.modl.backend.player.dto.request.StartupRequest; import gg.modl.backend.realtime.config.RealtimeProperties; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.Topic; @@ -17,9 +17,6 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -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.Service; @Service @@ -35,9 +32,9 @@ public class MinecraftStartupService { Topic.TOPIC_MINECRAFT_MIGRATION_TASKS ); - private final ModlProperties modlProperties; + private final PanelDomainResolver panelDomainResolver; private final RealtimeProperties realtimeProperties; - private final ServerMongoRepository serverRepository; + private final ServerActivityRepository serverActivityRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; public Map handleStartup(Server server, StartupRequest request, String clientIp) { @@ -63,20 +60,11 @@ public Map handleStartup( ) { Instant now = Instant.now(); - // Build panel URL - String domain = server.getCustomDomainOverride(); - if (domain == null || domain.isBlank()) { - domain = server.getCustomDomain() + "." + modlProperties.getDomain(); - } + String domain = panelDomainResolver.panelDomain(server); String panelUrl = "https://" + domain; - // Update lastActivityAt on the server - serverRepository.updateFirst( - Query.query(Criteria.where("_id").is(server.getId())), - new Update().set("lastActivityAt", Date.from(now)) - ); + serverActivityRepository.updateActivity(server, Date.from(now)); - // Store metrics via ServerInstanceSnapshotMongoRepository try { long epochSeconds = now.getEpochSecond(); Date fiveMinBoundary = Date.from(Instant.ofEpochSecond((epochSeconds / 300) * 300)); diff --git a/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java b/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java index 3aaacd3..684ff96 100644 --- a/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java +++ b/src/main/java/gg/modl/backend/player/service/MinecraftSyncService.java @@ -3,17 +3,21 @@ 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.ServerActivityRepository; import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.migration.data.MigrationStatus; import gg.modl.backend.player.data.Player; 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.data.punishment.EnforcementCategory; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncDataView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; +import gg.modl.backend.player.dto.response.SyncResult; 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.infrastructure.util.UuidUtils; import gg.modl.backend.staff.data.Staff; import java.net.URI; import java.time.Instant; @@ -24,13 +28,9 @@ import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; -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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -41,7 +41,7 @@ public class MinecraftSyncService { private final PlayerMongoRepository playerRepository; private final StaffMongoRepository staffRepository; - private final ServerMongoRepository serverRepository; + private final ServerActivityRepository serverActivityRepository; private final MigrationMongoRepository migrationRepository; private final ServerInstanceSnapshotMongoRepository serverInstanceSnapshotRepository; private final PlayerStatusCalculator statusCalculator; @@ -52,7 +52,7 @@ public class MinecraftSyncService { private final SyncStaffEventService syncStaffEventService; private final SyncActiveStaffService syncActiveStaffService; - public Map sync( + public SyncResult sync( Server server, String lastSyncTimestamp, List onlinePlayers, @@ -69,8 +69,8 @@ public Map sync( Instant lastSync = parseLastSync(lastSyncTimestamp, now); List types = punishmentTypeService.getPunishmentTypes(server); - List> pendingPunishments = new ArrayList<>(); - List> recentlyModifiedPunishments = new ArrayList<>(); + List pendingPunishments = new ArrayList<>(); + List recentlyModifiedPunishments = new ArrayList<>(); List> playerNotifications = new ArrayList<>(); Set onlineUuids = collectOnlineUuids(onlinePlayers); @@ -78,22 +78,7 @@ public Map sync( if (!onlineUuids.isEmpty()) { List players = playerRepository.findByMinecraftUuids(server, onlineUuids); - Set allIssuerIds = new HashSet<>(); - for (Player p : players) { - for (Punishment pun : p.getPunishments()) { - if (pun.getIssuerId() != null) { - allIssuerIds.add(pun.getIssuerId()); - } - for (PunishmentModification m : pun.getModifications()) { - if (m.issuerId() != null) { - allIssuerIds.add(m.issuerId()); - } - } - } - } - Map resolvedIssuers = allIssuerIds.isEmpty() - ? Map.of() - : issuerNameResolver.batchResolve(allIssuerIds, server); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, players); for (Player player : players) { List promoted = punishmentLifecycleService.promoteUnstartedPunishments(server, player); @@ -104,122 +89,8 @@ public Map sync( } } - String uuid = player.getMinecraftUuid().toString(); - String username = PlayerDataUtils.extractLatestUsername(player.getUsernames()); - - // Pass 1: classify all punishments in a single iteration - Set categoriesWithActiveStarted = new HashSet<>(); - Map oldestUnstartedPerCategory = new LinkedHashMap<>(); - Set pardonedCategories = new HashSet<>(); - - for (Punishment punishment : player.getPunishments()) { - boolean active = statusCalculator.isPunishmentActive(punishment); - String category = statusCalculator.getEffectiveCategory(punishment, types); - - boolean recentlyModified = punishment.getModifications() - .stream() - .anyMatch(mod -> mod.date() != null && mod.date().toInstant().isAfter(lastSync)); - if (recentlyModified) { - recentlyModifiedPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - } - - boolean recentlyPardoned = punishment.getModifications() - .stream() - .anyMatch(mod -> mod.date() != null - && mod.date().toInstant().isAfter(lastSync) - && PunishmentModificationType.isPardon(mod.type())); - if (recentlyPardoned && category != null) { - pardonedCategories.add(category); - } - - // Unstarted kicks (ordinal 0) - if (punishment.getTypeOrdinal() == 0 && punishment.getStarted() == null) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - continue; - } - - if (!active) { - continue; - } - - // Newly issued active punishments since last sync - if (punishment.getStarted() != null - && punishment.getIssued() != null - && punishment.getIssued().toInstant().isAfter(lastSync) - && category != null) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - } - - // Track active started vs unstarted per category - if (category != null && punishment.getStarted() != null) { - categoriesWithActiveStarted.add(category); - } else if (category != null && punishment.getStarted() == null) { - Punishment existing = oldestUnstartedPerCategory.get(category); - if (existing == null - || (punishment.getIssued() != null - && (existing.getIssued() == null - || punishment.getIssued().before(existing.getIssued())))) { - oldestUnstartedPerCategory.put(category, punishment); - } - } - } - - // Add oldest unstarted punishments that don't have an active started one in the same category - for (Map.Entry entry : oldestUnstartedPerCategory.entrySet()) { - if (!categoriesWithActiveStarted.contains(entry.getKey())) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(entry.getValue(), types, statusCalculator, resolvedIssuers) - )); - } - } - - // Pass 2: find active replacements for recently pardoned categories - if (!pardonedCategories.isEmpty()) { - for (Punishment punishment : player.getPunishments()) { - if (!statusCalculator.isPunishmentActive(punishment) || punishment.getStarted() == null) { - continue; - } - - String category = statusCalculator.getEffectiveCategory(punishment, types); - if (category != null && pardonedCategories.contains(category)) { - pendingPunishments.add(Map.of( - "minecraftUuid", uuid, - "username", username, - "punishment", PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers) - )); - pardonedCategories.remove(category); - } - } - } - - Map playerData = player.getData(); - Object rawPending = playerData != null ? playerData.get("pendingNotifications") : null; - if (rawPending instanceof List pendingList) { - for (Object item : pendingList) { - if (!(item instanceof Map notification)) { - continue; - } - @SuppressWarnings("unchecked") - Map typedNotification = (Map) notification; - Map payload = new HashMap<>(typedNotification); - payload.put("targetPlayerUuid", uuid); - playerNotifications.add(payload); - } - } + collectPlayerPunishments(player, types, lastSync, resolvedIssuers, pendingPunishments, recentlyModifiedPunishments); + collectPlayerNotifications(player, playerNotifications); } } @@ -230,7 +101,7 @@ public Map sync( if (onlinePlayers != null) { for (OnlinePlayerInput onlinePlayer : onlinePlayers) { if (onlinePlayer.uuid() != null && onlinePlayer.ipAddress() != null) { - onlinePlayerIps.put(normalizeUuid(onlinePlayer.uuid()), onlinePlayer.ipAddress()); + onlinePlayerIps.put(UuidUtils.normalize(onlinePlayer.uuid()), onlinePlayer.ipAddress()); } } } @@ -239,58 +110,173 @@ public Map sync( submitLogs(server, chatLogs, commandLogs); - Map data = new LinkedHashMap<>(); - data.put("pendingPunishments", pendingPunishments); - data.put("recentlyStartedPunishments", List.of()); - data.put("recentlyModifiedPunishments", recentlyModifiedPunishments); - data.put("playerNotifications", playerNotifications); - data.put("staffNotifications", staffNotifications); - data.put("activeStaffMembers", activeStaffMembers); - data.put("pendingStatWipes", List.of()); - data.put("staffPermissionsUpdatedAt", server.getStaffPermissionsUpdatedAt() != null - ? server.getStaffPermissionsUpdatedAt().getTime() - : null); - data.put("punishmentTypesUpdatedAt", server.getPunishmentTypesUpdatedAt() != null - ? server.getPunishmentTypesUpdatedAt().getTime() - : null); + Long staffPermissionsUpdatedAt = server.getStaffPermissionsUpdatedAt() != null + ? server.getStaffPermissionsUpdatedAt().getTime() + : null; + Long punishmentTypesUpdatedAt = server.getPunishmentTypesUpdatedAt() != null + ? server.getPunishmentTypesUpdatedAt().getTime() + : null; + List> staff2faVerifications = collectStaff2faVerifications(server); + Map migrationTask = collectMigrationTask(server); + + applyServerStatus(server, serverStatus, serverName, clientIp, now); + + SyncDataView data = new SyncDataView( + pendingPunishments, + List.of(), + recentlyModifiedPunishments, + playerNotifications, + staffNotifications, + activeStaffMembers, + List.of(), + staffPermissionsUpdatedAt, + punishmentTypesUpdatedAt, + staff2faVerifications, + migrationTask + ); + + return new SyncResult(now.toString(), data); + } + private void collectPlayerPunishments( + Player player, + List types, + Instant lastSync, + Map resolvedIssuers, + List pendingPunishments, + List recentlyModifiedPunishments + ) { + String uuid = player.getMinecraftUuid().toString(); + String username = PlayerDataUtils.extractLatestUsername(player.getUsernames()); + + Set categoriesWithActiveStarted = new HashSet<>(); + Map oldestUnstartedPerCategory = new LinkedHashMap<>(); + Set pardonedCategories = new HashSet<>(); + + for (Punishment punishment : player.getPunishments()) { + boolean active = statusCalculator.isPunishmentActive(punishment); + String category = statusCalculator.getEffectiveCategory(punishment, types); + + boolean recentlyModified = punishment.getModifications() + .stream() + .anyMatch(mod -> mod.date() != null && mod.date().toInstant().isAfter(lastSync)); + if (recentlyModified) { + recentlyModifiedPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + } + + boolean recentlyPardoned = punishment.getModifications() + .stream() + .anyMatch(mod -> mod.date() != null + && mod.date().toInstant().isAfter(lastSync) + && PunishmentModificationType.isPardon(mod.type())); + if (recentlyPardoned && category != null) { + pardonedCategories.add(category); + } + + if (punishment.getTypeOrdinal() == 0 && punishment.getStarted() == null) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + continue; + } + + if (!active) { + continue; + } + + if (punishment.getStarted() != null + && punishment.getIssued() != null + && punishment.getIssued().toInstant().isAfter(lastSync) + && category != null) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + } + + if (category != null && punishment.getStarted() != null) { + categoriesWithActiveStarted.add(category); + } else if (category != null && punishment.getStarted() == null) { + Punishment existing = oldestUnstartedPerCategory.get(category); + if (existing == null + || (punishment.getIssued() != null + && (existing.getIssued() == null + || punishment.getIssued().before(existing.getIssued())))) { + oldestUnstartedPerCategory.put(category, punishment); + } + } + } + + for (Map.Entry entry : oldestUnstartedPerCategory.entrySet()) { + if (!categoriesWithActiveStarted.contains(entry.getKey())) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(entry.getValue(), types, statusCalculator, resolvedIssuers))); + } + } + + if (!pardonedCategories.isEmpty()) { + for (Punishment punishment : player.getPunishments()) { + if (!statusCalculator.isPunishmentActive(punishment) || punishment.getStarted() == null) { + continue; + } + + String category = statusCalculator.getEffectiveCategory(punishment, types); + if (category != null && pardonedCategories.contains(category)) { + pendingPunishments.add(new SyncPunishmentEntry( + uuid, username, + PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers))); + pardonedCategories.remove(category); + } + } + } + } + + private void collectPlayerNotifications(Player player, List> playerNotifications) { + String uuid = player.getMinecraftUuid().toString(); + for (Map notification : player.data().pendingNotifications()) { + Map payload = new HashMap<>(notification); + payload.put("targetPlayerUuid", uuid); + playerNotifications.add(payload); + } + } + + private List> collectStaff2faVerifications(Server server) { try { List pendingStaff = staffRepository.findWithPendingTwoFactorDelivery(server); if (!pendingStaff.isEmpty()) { - data.put("staff2faVerifications", pendingStaff.stream() + List> verifications = pendingStaff.stream() .map(staff -> Map.of("minecraftUuid", staff.getAssignedMinecraftUuid())) - .toList()); + .toList(); staffRepository.clearPendingTwoFactorDelivery(server); + return verifications; } } catch (Exception e) { log.warn("Failed to process 2FA verifications during sync", e); } + return null; + } + private Map collectMigrationTask(Server server) { try { Optional activeMigration = migrationRepository.findActiveMigration(server); - activeMigration.ifPresent(migration -> data.put("migrationTask", Map.of( - "taskId", migration.getTaskId(), - "type", migration.getType() - ))); + if (activeMigration.isPresent()) { + MigrationStatus migration = activeMigration.get(); + return Map.of( + "taskId", migration.getTaskId(), + "type", migration.getType() + ); + } } catch (Exception e) { log.warn("Failed to check active migration during sync", e); } - - applyServerStatus(server, serverStatus, serverName, clientIp, now); - - return Map.of( - "timestamp", now.toString(), - "data", data - ); + return null; } public void applyPresence(Server server, List onlinePlayers, String serverName, Instant now) { - serverRepository.updateFirst( - Query.query(Criteria.where("_id").is(server.getId())), - new Update() - .set("lastActivityAt", Date.from(now)) - .set("onlinePlayerCount", onlinePlayers != null ? (long) onlinePlayers.size() : 0L) - ); + serverActivityRepository.updateActivityAndPlayerCount(server, Date.from(now), + onlinePlayers != null ? (long) onlinePlayers.size() : 0L); playerRepository.markStalePlayersOffline(server, collectOnlineUuids(onlinePlayers), serverName, Date.from(now)); } @@ -360,41 +346,27 @@ private Set collectOnlineUuids(List onlinePlayers) { if (onlinePlayers != null) { for (OnlinePlayerInput onlinePlayer : onlinePlayers) { if (onlinePlayer.uuid() != null) { - onlineUuids.add(normalizeUuid(onlinePlayer.uuid())); + onlineUuids.add(UuidUtils.normalize(onlinePlayer.uuid())); } } } return onlineUuids; } - private List> deduplicatePendingPunishments(List> punishments) { - Map> oldestByPlayerCategory = new LinkedHashMap<>(); - List> result = new ArrayList<>(); - - for (Map entry : punishments) { - String uuid = entry.get("minecraftUuid") instanceof String value ? value : ""; - if (!(entry.get("punishment") instanceof Map rawPunishment)) { - result.add(entry); - continue; - } - - @SuppressWarnings("unchecked") - Map punishment = (Map) rawPunishment; - String category = punishment.get("category") instanceof String value ? value : null; + private List deduplicatePendingPunishments(List punishments) { + Map oldestByPlayerCategory = new LinkedHashMap<>(); + List result = new ArrayList<>(); + for (SyncPunishmentEntry entry : punishments) { + SimplePunishmentView punishment = entry.punishment(); + String category = punishment.category(); if (EnforcementCategory.BAN.name().equals(category) || EnforcementCategory.MUTE.name().equals(category)) { - String key = uuid + "|" + category; - Map existing = oldestByPlayerCategory.get(key); + String key = entry.minecraftUuid() + "|" + category; + SyncPunishmentEntry existing = oldestByPlayerCategory.get(key); if (existing == null) { oldestByPlayerCategory.put(key, entry); - } else if (existing.get("punishment") instanceof Map rawExisting) { - @SuppressWarnings("unchecked") - Map existingPunishment = (Map) rawExisting; - long existingIssued = existingPunishment.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - long currentIssued = punishment.get("issuedAt") instanceof Number number ? number.longValue() : 0L; - if (currentIssued < existingIssued) { - oldestByPlayerCategory.put(key, entry); - } + } else if (punishment.issuedAt() < existing.punishment().issuedAt()) { + oldestByPlayerCategory.put(key, entry); } } else { result.add(entry); @@ -405,7 +377,7 @@ private List> deduplicatePendingPunishments(List syncV2( + public SyncResult syncV2( Server server, String lastSyncTimestamp, List onlinePlayers, @@ -414,7 +386,7 @@ public Map syncV2( List commandLogs, String clientIp ) { - Map result = sync( + SyncResult result = sync( server, lastSyncTimestamp, onlinePlayers, @@ -425,22 +397,12 @@ public Map syncV2( clientIp ); - convertUrlsToRelativePaths(result); + convertTicketUrlsInNotifications(result.data().staffNotifications()); + convertTicketUrlsInNotifications(result.data().playerNotifications()); return result; } - @SuppressWarnings("unchecked") - private void convertUrlsToRelativePaths(Map result) { - Object dataObj = result.get("data"); - if (!(dataObj instanceof Map data)) { - return; - } - - convertTicketUrlsInNotifications((List>) data.get("staffNotifications")); - convertTicketUrlsInNotifications((List>) data.get("playerNotifications")); - } - @SuppressWarnings("unchecked") private void convertTicketUrlsInNotifications(List> notifications) { if (notifications == null) { @@ -480,8 +442,4 @@ public record ChatLogInput(String uuid, String username, String message, long ti public record CommandLogInput(String uuid, String username, String command, long timestamp, String server) { } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } 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 948bf9d..c74511c 100644 --- a/src/main/java/gg/modl/backend/player/service/MojangApiService.java +++ b/src/main/java/gg/modl/backend/player/service/MojangApiService.java @@ -1,5 +1,8 @@ package gg.modl.backend.player.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -7,8 +10,6 @@ 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; @@ -17,10 +18,9 @@ public class MojangApiService { private final HttpClient httpClient; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); 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() @@ -49,19 +49,21 @@ public Optional lookupByUsername(String username) { } } - private Optional parseProfile(String json) { - Matcher nameMatcher = NAME_PATTERN.matcher(json); - Matcher idMatcher = ID_PATTERN.matcher(json); - - if (!nameMatcher.find() || !idMatcher.find()) { + static Optional parseProfile(String json) { + JsonNode root; + try { + root = OBJECT_MAPPER.readTree(json); + } catch (JsonProcessingException e) { return Optional.empty(); } - String name = nameMatcher.group(1); - String rawId = idMatcher.group(1); - UUID uuid = fromDashlessUuid(rawId); + JsonNode nameNode = root.get("name"); + JsonNode idNode = root.get("id"); + if (nameNode == null || !nameNode.isTextual() || idNode == null || !idNode.isTextual()) { + return Optional.empty(); + } - return Optional.of(new MojangProfile(name, uuid)); + return Optional.of(new MojangProfile(nameNode.asText(), fromDashlessUuid(idNode.asText()))); } private static UUID fromDashlessUuid(String id) { diff --git a/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java b/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java index 6d7dc29..fa93379 100644 --- a/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java +++ b/src/main/java/gg/modl/backend/player/service/PlayerLookupService.java @@ -1,34 +1,35 @@ package gg.modl.backend.player.service; import gg.modl.backend.database.mongo.repository.PlayerMongoRepository; -import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.infrastructure.util.PaginationHelper; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.player.PlayerService; -import gg.modl.backend.player.service.PlayerDataUtils; 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.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.dto.response.LinkedAccountsResult; +import gg.modl.backend.player.dto.response.PlayerFetchResult; +import gg.modl.backend.player.dto.response.PlayerLookupResult; +import gg.modl.backend.player.dto.response.PlayerProfileResult; +import gg.modl.backend.player.dto.response.PunishmentView; 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 java.util.ArrayList; -import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @Service @@ -40,75 +41,58 @@ public class PlayerLookupService { private final MojangApiService mojangApiService; private final PlayerStatusCalculator statusCalculator; private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; private final PlayerService playerService; - public MinecraftPlayerService.ServiceResponse getPlayerByUuid(Server server, String uuid, Integer punishmentLimit, Integer noteLimit) { + public PlayerProfileResult getPlayerByUuid(Server server, String uuid, Integer punishmentLimit, Integer noteLimit) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new PlayerProfileResult.NotFound("Player not found"); } List punishmentTypes = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of("status", 200, "profile", toPlayerProfile(server, player, punishmentTypes, punishmentLimit, noteLimit))); + return new PlayerProfileResult.Found(toPlayerProfile(server, player, punishmentTypes, punishmentLimit, noteLimit)); } - public MinecraftPlayerService.ServiceResponse getPlayerByMinecraftUuid(Server server, String minecraftUuid, boolean queryMojang) { + public PlayerFetchResult getPlayerByMinecraftUuid(Server server, String minecraftUuid, boolean queryMojang) { if (minecraftUuid == null || minecraftUuid.isBlank()) { - return badRequest("minecraftUuid parameter required"); + return new PlayerFetchResult.InvalidRequest("minecraftUuid parameter required"); } Player player = findPlayerByUuid(server, minecraftUuid).orElse(null); if (player == null && queryMojang) { Optional profile = mojangApiService.lookupByUuid(minecraftUuid); if (profile.isPresent()) { - return ok(Map.of( - "status", 200, - "message", "Player found via Mojang", - "player", toMojangProfile(profile.get()) - )); + return new PlayerFetchResult.Found("Player found via Mojang", toMojangProfile(profile.get())); } } if (player == null) { - return notFound("Player not found"); + return new PlayerFetchResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of( - "status", 200, - "message", "Player found", - "player", toPlayerProfile(server, player, types) - )); + return new PlayerFetchResult.Found("Player found", toPlayerProfile(server, player, types)); } - public MinecraftPlayerService.ServiceResponse getPlayerByUsername(Server server, String username, boolean queryMojang) { + public PlayerFetchResult getPlayerByUsername(Server server, String username, boolean queryMojang) { Player player = findByUsername(server, username).orElse(null); if (player == null && queryMojang) { Optional profile = mojangApiService.lookupByUsername(username); if (profile.isPresent()) { - return ok(Map.of( - "status", 200, - "message", "Player found via Mojang", - "player", toMojangProfile(profile.get()) - )); + return new PlayerFetchResult.Found("Player found via Mojang", toMojangProfile(profile.get())); } } if (player == null) { - return notFound("Player not found"); + return new PlayerFetchResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of( - "status", 200, - "message", "Player found", - "player", toPlayerProfile(server, player, types) - )); + return new PlayerFetchResult.Found("Player found", toPlayerProfile(server, player, types)); } - public MinecraftPlayerService.ServiceResponse lookupPlayer(Server server, String query, boolean shouldQueryMojang) { - boolean isUuid = query.contains("-") && query.length() == 36; + public PlayerLookupResult lookupPlayer(Server server, String query, boolean shouldQueryMojang) { + boolean isUuid = looksLikeUuid(query); Player player = isUuid ? findPlayerByUuid(server, query).orElse(null) : findByUsername(server, query).orElse(null); @@ -135,28 +119,20 @@ public MinecraftPlayerService.ServiceResponse lookupPlayer(Server server, String "warnings", 0 )); lookupData.put("recentPunishments", List.of()); - return ok(Map.of( - "status", 200, - "message", "Player found via Mojang", - "data", lookupData - )); + return new PlayerLookupResult.Found("Player found via Mojang", lookupData); } } if (player == null) { - return notFound("Player not found"); + return new PlayerLookupResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of( - "status", 200, - "message", "Player found", - "data", buildLookupResponse(server, player, types) - )); + return new PlayerLookupResult.Found("Player found", buildLookupResponse(server, player, types)); } - public MinecraftPlayerService.ServiceResponse lookupProfile(Server server, String query, boolean shouldQueryMojang, Integer punishmentLimit, Integer noteLimit) { - boolean isUuid = query.contains("-") && query.length() == 36; + public PlayerProfileResult lookupProfile(Server server, String query, boolean shouldQueryMojang, Integer punishmentLimit, Integer noteLimit) { + boolean isUuid = looksLikeUuid(query); Player player = isUuid ? findPlayerByUuid(server, query).orElse(null) : findByUsername(server, query).orElse(null); @@ -174,11 +150,11 @@ public MinecraftPlayerService.ServiceResponse lookupProfile(Server server, Strin } if (player == null) { - return notFound("Player not found"); + return new PlayerProfileResult.NotFound("Player not found"); } List types = punishmentTypeService.getPunishmentTypes(server); - return ok(Map.of("status", 200, "profile", toPlayerProfile(server, player, types, punishmentLimit, noteLimit))); + return new PlayerProfileResult.Found(toPlayerProfile(server, player, types, punishmentLimit, noteLimit)); } Map toPlayerProfile(Server server, Player player, List punishmentTypes) { @@ -187,7 +163,7 @@ Map toPlayerProfile(Server server, Player player, List toPlayerProfile(Server server, Player player, List punishmentTypes, Integer punishmentLimit, Integer noteLimit) { return toPlayerProfile(server, player, punishmentTypes, punishmentLimit, noteLimit, - resolveIssuersForPunishments(server, safePunishments(player))); + issuerNameResolver.resolveForPunishments(server, safePunishments(player))); } Map toPlayerProfile(Server server, Player player, List punishmentTypes, Integer punishmentLimit, Integer noteLimit, Map resolvedIssuers) { @@ -232,11 +208,11 @@ Map toPlayerProfile(Server server, Player player, List allPunishments = safePunishments(player); List limitedPunishments = limitNewestFirst(allPunishments, punishmentLimit, Comparator.comparing(Punishment::getIssued, Comparator.nullsLast(Comparator.naturalOrder())).reversed()); - List> punishments = limitedPunishments.stream() - .map(punishment -> PunishmentMapper.toPunishmentMap(punishment, punishmentTypes, resolvedIssuers)) + List punishments = limitedPunishments.stream() + .map(punishment -> PunishmentMapper.toPunishmentView(punishment, punishmentTypes, resolvedIssuers)) .toList(); - List> pendingNotifications = extractPendingNotifications(player); + List> pendingNotifications = player.data().pendingNotifications(); Map profile = new LinkedHashMap<>(); profile.put("id", player.getId()); @@ -246,7 +222,7 @@ Map toPlayerProfile(Server server, Player player, List buildLookupResponse(Server server, Player player, Li String currentUsername = PlayerDataUtils.extractLatestUsername(safeUsernames(player)); List previousUsernames = player.getUsernames() .stream() - .map(username -> username.username()) + .map(UsernameEntry::username) .skip(1) .toList(); @@ -322,7 +298,7 @@ private Map buildLookupResponse(Server server, Player player, Li data.put("previousUsernames", previousUsernames); data.put("firstSeen", firstSeen); data.put("lastSeen", lastSeen); - data.put("isOnline", player.getData().getOrDefault("isOnline", false)); + data.put("isOnline", player.data().isOnline()); data.put("punishmentStats", Map.of( "totalPunishments", totalPunishments, "activePunishments", activePunishments, @@ -350,32 +326,15 @@ private Map toMojangProfile(MojangApiService.MojangProfile profi } private Optional findPlayerByUuid(Server server, String uuid) { - return playerRepository.findByMinecraftUuid(server, normalizeUuid(uuid)); + return playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(uuid)); } private Optional findByUsername(Server server, String username) { return playerService.findBestByUsername(server, username); } - private Map resolveIssuersForPlayers(Server server, List players) { - Set ids = new HashSet<>(); - for (Player player : players) { - for (Punishment punishment : safePunishments(player)) { - ids.addAll(PunishmentQueryService.collectIssuerIds(punishment)); - } - } - return ids.isEmpty() ? Map.of() : issuerNameResolver.batchResolve(ids, server); - } - - private Map resolveIssuersForPunishments(Server server, List punishments) { - Set ids = new HashSet<>(); - for (Punishment p : punishments) { - ids.addAll(PunishmentQueryService.collectIssuerIds(p)); - } - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); + private boolean looksLikeUuid(String query) { + return query.contains("-") && query.length() == 36; } private List safeUsernames(Player player) { @@ -404,61 +363,25 @@ private List limitNewestFirst(List values, Integer limit, Comparator> extractPendingNotifications(Player player) { - if (player.getData() == null) { - return List.of(); - } - Object rawPending = player.getData().getOrDefault("pendingNotifications", Collections.emptyList()); - if (!(rawPending instanceof List list)) { - return List.of(); + public LinkedAccountsResult getLinkedAccounts(Server server, String uuid, Integer page, Integer limit) { + LinkedAccountsResult fullResult = getLinkedAccountsFull(server, uuid); + if (!(fullResult instanceof LinkedAccountsResult.Found found) || page == null || limit == null) { + return fullResult; } - return list.stream() - .filter(entry -> entry instanceof Map) - .map(entry -> { - @SuppressWarnings("unchecked") - Map notification = (Map) entry; - return notification; - }) - .toList(); + PaginationHelper.PageResult> result = PaginationHelper.paginate(found.linkedAccounts(), page, limit); + return new LinkedAccountsResult.Found(result.items(), result.totalCount(), result.page(), result.hasMore()); } - private MinecraftPlayerService.ServiceResponse ok(Map body) { - return new MinecraftPlayerService.ServiceResponse(HttpStatus.OK, body); - } - - private MinecraftPlayerService.ServiceResponse notFound(String message) { - return new MinecraftPlayerService.ServiceResponse(HttpStatus.NOT_FOUND, Map.of("status", 404, "message", message)); - } - - public MinecraftPlayerService.ServiceResponse getLinkedAccounts(Server server, String uuid, Integer page, Integer limit) { - MinecraftPlayerService.ServiceResponse fullResponse = getLinkedAccountsFull(server, uuid); - if (fullResponse.status() != HttpStatus.OK || page == null || limit == null) { - return fullResponse; - } - - @SuppressWarnings("unchecked") - List> allAccounts = (List>) fullResponse.body().get("linkedAccounts"); - PaginationHelper.PageResult> result = PaginationHelper.paginate(allAccounts, page, limit); - - Map response = new LinkedHashMap<>(); - response.put("status", 200); - response.put("linkedAccounts", result.items()); - response.put("totalCount", result.totalCount()); - response.put("page", result.page()); - response.put("hasMore", result.hasMore()); - return ok(response); - } - - private MinecraftPlayerService.ServiceResponse getLinkedAccountsFull(Server server, String uuid) { + private LinkedAccountsResult getLinkedAccountsFull(Server server, String uuid) { Player player = findPlayerByUuid(server, uuid).orElse(null); if (player == null) { - return notFound("Player not found"); + return new LinkedAccountsResult.NotFound("Player not found"); } List ips = safeIpAddresses(player).stream() - .map(ip -> ip.getIpAddress()) + .map(IPEntry::getIpAddress) .toList(); List types = punishmentTypeService.getPunishmentTypes(server); @@ -466,39 +389,25 @@ private MinecraftPlayerService.ServiceResponse getLinkedAccountsFull(Server serv List linkedPlayers = new ArrayList<>(); if (!ips.isEmpty()) { - for (Player related : playerRepository.findByIpAddressesExcludingUuid(server, ips, normalizeUuid(uuid), 20)) { + for (Player related : playerRepository.findByIpAddressesExcludingUuid(server, ips, UuidUtils.normalize(uuid), 20)) { linkedPlayers.add(related); addedUuids.add(related.getMinecraftUuid().toString()); } } - if (player.getData() != null && player.getData().containsKey("linkedAccounts")) { - Object rawLinked = player.getData().get("linkedAccounts"); - List storedLinkedUuids = rawLinked instanceof List list - ? list.stream().filter(String.class::isInstance).map(String.class::cast).toList() - : List.of(); - - List missingUuids = storedLinkedUuids.stream() - .filter(linkedUuid -> !addedUuids.contains(linkedUuid)) - .toList(); - if (!missingUuids.isEmpty()) { - linkedPlayers.addAll(playerRepository.findByMinecraftUuids(server, missingUuids)); - } + List storedLinkedUuids = player.data().linkedAccountUuids(); + List missingUuids = storedLinkedUuids.stream() + .filter(linkedUuid -> !addedUuids.contains(linkedUuid)) + .toList(); + if (!missingUuids.isEmpty()) { + linkedPlayers.addAll(playerRepository.findByMinecraftUuids(server, missingUuids)); } - Map resolvedIssuers = resolveIssuersForPlayers(server, linkedPlayers); + Map resolvedIssuers = issuerNameResolver.resolveForPlayers(server, linkedPlayers); List> linkedAccounts = linkedPlayers.stream() .map(linked -> toPlayerProfile(server, linked, types, null, null, resolvedIssuers)) .toList(); - return ok(Map.of("status", 200, "linkedAccounts", linkedAccounts)); - } - - private MinecraftPlayerService.ServiceResponse badRequest(String message) { - return new MinecraftPlayerService.ServiceResponse(HttpStatus.BAD_REQUEST, Map.of("status", 400, "message", message)); - } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); + return new LinkedAccountsResult.Found(linkedAccounts, null, null, null); } } diff --git a/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java b/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java index 6b3d291..20a62c2 100644 --- a/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java +++ b/src/main/java/gg/modl/backend/player/service/PlayerStatusCalculator.java @@ -2,7 +2,7 @@ import gg.modl.backend.player.data.punishment.EnforcementCategory; import gg.modl.backend.player.data.punishment.Punishment; -import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentModification; import gg.modl.backend.player.data.punishment.PunishmentModificationType; import gg.modl.backend.player.data.punishment.PunishmentStatus; @@ -16,8 +16,8 @@ import gg.modl.backend.settings.service.PunishmentTypeService; import java.util.Date; import java.util.List; +import java.util.Locale; import java.util.Map; -import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -46,8 +46,7 @@ public PlayerStatus calculateStatus(Server server, List punishments) } int typeOrdinal = punishment.getTypeOrdinal(); - Map data = punishment.getData(); - String severity = PunishmentData.getSeverity(data); + String severity = punishment.data().severity(); PunishmentType type = typesByOrdinal.get(typeOrdinal); if (type == null) { @@ -91,30 +90,22 @@ private boolean isPunishmentEligible(Punishment punishment) { return false; } - Map data = punishment.getData(); - if (data == null) { + PunishmentDataView data = punishment.data(); + if (data.asMap() == null) { return false; } - if (PunishmentStatus.UNSTARTED.equals(PunishmentData.getStatus(data))) { + if (data.isUnstarted()) { return false; } - return !isPardoned(punishment); + return !punishment.isPardoned(); } - private boolean isPardoned(Punishment punishment) { - for (PunishmentModification mod : punishment.getModifications()) { - if (PunishmentModificationType.isPardon(mod.type())) { - return true; - } - } - return false; - } public Date getEffectiveExpiry(Punishment punishment) { - Map data = punishment.getData(); - if (data == null) { + PunishmentDataView data = punishment.data(); + if (data.asMap() == null) { return null; } @@ -133,49 +124,29 @@ public Date getEffectiveExpiry(Punishment punishment) { return new Date(latestDurationChange.date().getTime() + eff); } - Long duration = PunishmentData.getDuration(data); - // null, 0, or negative (-1L) indicates permanent (no expiry) + Long duration = data.duration(); if (duration == null || duration <= 0) { return null; } - // Count from started date, or current time if not yet started - // (unstarted punishments use current time so the plugin receives a proper - // expiration for display — nothing is persisted until the plugin acknowledges) Date baseDate = punishment.getStarted() != null ? punishment.getStarted() : new Date(); return new Date(baseDate.getTime() + duration); } - private Optional findTypeByOrdinal(Map typesByOrdinal, int ordinal) { - return Optional.ofNullable(typesByOrdinal.get(ordinal)); - } - - /** - * Determine the effective enforcement category for a punishment. - * Core types use isBan()/isMute()/isKick(). - * Social/gameplay types use the DurationDetail for the stored severity and offense level. - * - * @return EnforcementCategory name, or null (for kicks and unknown types) - */ public String getEffectiveCategory(Punishment punishment, List types) { return getEffectiveCategory(punishment, PunishmentTypeIndex.byOrdinal(types)); } public String getEffectiveCategory(Punishment punishment, Map typesByOrdinal) { PunishmentType pt = typesByOrdinal.get(punishment.getTypeOrdinal()); - return getEffectiveCategory(pt, punishment.getData()); + return getEffectiveCategory(pt, punishment.data()); } - /** - * Determine the effective enforcement category for a punishment type with given data. - * - * @return EnforcementCategory name, or null - */ - public String getEffectiveCategory(PunishmentType pt, Map data) { + public String getEffectiveCategory(PunishmentType pt, PunishmentDataView data) { if (pt == null) { return null; } - String storedCategory = PunishmentData.getEnforcementCategory(data); + String storedCategory = data.enforcementCategory(); if (storedCategory != null) { return storedCategory; } @@ -189,21 +160,20 @@ public String getEffectiveCategory(PunishmentType pt, Map data) return EnforcementCategory.MUTE.name(); } - // Social/gameplay types: determine from DurationDetail - if (data != null) { - String severity = PunishmentData.getSeverity(data) != null ? PunishmentData.getSeverity(data) : "regular"; + if (data.asMap() != null) { + String severity = data.severity() != null ? data.severity() : "regular"; String offenseLevel; - String rawOffenseLevel = PunishmentData.getOffenseLevel(data); + String rawOffenseLevel = data.offenseLevel(); if (rawOffenseLevel != null) { offenseLevel = rawOffenseLevel; } else { - String status = PunishmentData.getStatus(data); + String status = data.status(); if (status == null || PunishmentStatus.UNSTARTED.equals(status) || PunishmentStatus.PARDONED.equals(status)) { offenseLevel = "first"; } else { - offenseLevel = switch (status.toLowerCase()) { + offenseLevel = switch (status.toLowerCase(Locale.ROOT)) { case "low" -> "first"; case "medium" -> "medium"; case "habitual" -> "habitual"; @@ -224,32 +194,24 @@ public String getEffectiveCategory(PunishmentType pt, Map data) return null; } - /** - * Returns true when a punishment has naturally expired (not pardoned, not permanent, not a kick). - * Used to detect punishments eligible for stat-wipe execution. - */ public boolean isPunishmentNaturallyExpired(Punishment punishment) { - // Must have been started if (punishment.getStarted() == null) { return false; } - // Kicks are instant, not expirable if (punishment.getTypeOrdinal() == 0) { return false; } - if (isPardoned(punishment)) { + if (punishment.isPardoned()) { return false; } - // Must have a finite expiry (not permanent) Date effectiveExpiry = getEffectiveExpiry(punishment); if (effectiveExpiry == null) { return false; } - // Must have expired return effectiveExpiry.before(new Date()); } diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java b/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java index c960629..e28f7d5 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentDurationCalculator.java @@ -46,10 +46,7 @@ public DurationResult calculate(Server server, List existingPunishme DurationDetail durationDetail = PunishmentDurationResolver.resolveDetail(punishmentType, internalSeverity, offenseLevel); - String displayStatus = switch (offenseLevel) { - case "first" -> "low"; - default -> offenseLevel; - }; + String displayStatus = PunishmentMapper.offenseDisplayStatus(offenseLevel); Long calculatedDuration = null; if (durationDetail != null) { 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 bca836d..c39dd67 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentEvidenceService.java @@ -37,7 +37,7 @@ public PunishmentOperationResult addEvidence(Server server, String punishmentId, return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); } - String resolvedIssuerName = issuerId != null ? null : issuerName; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); Date now = new Date(); PunishmentEvidence evidence = new PunishmentEvidence( null, @@ -70,13 +70,13 @@ public Player addEvidence(Server server, UUID playerUuid, String punishmentId, A Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.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"); + String evIssuerName = PunishmentMapper.storedName(evIssuerId, request.issuerName() != null ? request.issuerName() : "System"); PunishmentEvidence evidence = new PunishmentEvidence( request.text(), @@ -95,17 +95,13 @@ public Player addEvidence(Server server, UUID playerUuid, String punishmentId, A 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; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); Date now = new Date(); List evidenceList = new ArrayList<>(); for (UploadedEvidenceItem evidenceItem : evidenceItems) { @@ -142,7 +138,7 @@ public PunishmentOperationResult addPunishmentNote(Server server, String punishm return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); } - String resolvedIssuerName = issuerId != null ? null : issuerName; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, 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); @@ -154,12 +150,12 @@ public Player addPunishmentNote(Server server, UUID playerUuid, String punishmen Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()) .orElseThrow(() -> new ResourceNotFoundException("Player not found")); - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); if (punishment == null) { throw new ResourceNotFoundException("Punishment not found"); } - String resolvedIssuerName = issuerId != null ? null : issuerName; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, 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); diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java b/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java index 5fa321f..78298bf 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentLifecycleService.java @@ -5,10 +5,11 @@ import gg.modl.backend.database.mongo.repository.StaffMongoRepository; import gg.modl.backend.ticket.service.TicketService; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; -import gg.modl.backend.player.controller.MinecraftPunishmentController.MinecraftCreatePunishmentRequest; +import gg.modl.backend.player.dto.request.MinecraftCreatePunishmentRequest; 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.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentEvidence; import gg.modl.backend.player.data.punishment.PunishmentModification; import gg.modl.backend.player.data.punishment.PunishmentModificationType; @@ -68,19 +69,21 @@ public class PunishmentLifecycleService { private final PunishmentRealtimePublisher realtimePublisher; private final LogService logService; + private static final String SYSTEM_ISSUER = "System"; + private static final Set CLIENT_SETTABLE_PUNISHMENT_DATA_KEYS = Set.of( - "altBlocking", - "wipeAfterExpiry", + PunishmentData.ALT_BLOCKING, + PunishmentData.WIPE_AFTER_EXPIRY, "banLinkedAccounts", "kickSameIP", "silent", - "linkedBanId", - "severity", - "status", - "duration", - "reason", + PunishmentData.LINKED_BAN_ID, + PunishmentData.SEVERITY, + PunishmentData.STATUS, + PunishmentData.DURATION, + PunishmentData.REASON, "aiGenerated", - "pendingAcknowledgement" + PunishmentData.PENDING_ACKNOWLEDGEMENT ); private Map filterClientSettableData(Map requestData) { @@ -144,7 +147,7 @@ public String createMinecraftPunishment(Server server, MinecraftCreatePunishment } Map data = request.data() != null ? MongoKeyUtils.sanitizeKeys(new HashMap<>(request.data())) : new HashMap<>(); - data.put("pendingAcknowledgement", true); + PunishmentDataView.ofMap(data).setPendingAcknowledgement(true); CreatePunishmentRequest serviceRequest = new CreatePunishmentRequest( request.issuerName(), @@ -172,12 +175,13 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR } Date now = new Date(); Map data = filterClientSettableData(request.data()); + PunishmentDataView view = PunishmentDataView.ofMap(data); if (request.severity() != null) { - data.put("severity", request.severity()); + view.setSeverity(request.severity()); } if (request.status() != null) { - data.put("status", request.status()); + view.setStatus(request.status()); } Long calculatedDuration = request.duration(); @@ -186,48 +190,34 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR durationCalculator.calculate(server, player.getPunishments(), request.typeOrdinal(), request.severity()); calculatedDuration = result.duration(); - if (result.status() != null && (!data.containsKey("status") || PunishmentData.getStatus(data) == null)) { - data.put("status", result.status()); + if (result.status() != null && (!view.hasStatus() || view.status() == null)) { + view.setStatus(result.status()); } - if (result.offenseLevel() != null && !data.containsKey("offenseLevel")) { - data.put("offenseLevel", result.offenseLevel()); + if (result.offenseLevel() != null && !view.hasOffenseLevel()) { + view.setOffenseLevel(result.offenseLevel()); } } if (calculatedDuration == null) { - calculatedDuration = PunishmentData.getDuration(data); + calculatedDuration = view.duration(); } if (calculatedDuration != null && calculatedDuration != 0) { - data.put("duration", calculatedDuration); + view.setDuration(calculatedDuration); } if (request.reason() != null && !request.reason().isBlank()) { - data.put("reason", request.reason()); + view.setReason(request.reason()); } List types = punishmentTypeService.getPunishmentTypes(server); Map typesByOrdinal = PunishmentTypeIndex.byOrdinal(types); PunishmentType newPunishmentType = typesByOrdinal.get(request.typeOrdinal()); - if (newPunishmentType != null) { - if (newPunishmentType.isPermanentUntilUsernameChange() && !data.containsKey("blockedName")) { - String currentUsername = PlayerDataUtils.extractLatestUsername(player.getUsernames()); - currentUsername = "Unknown".equals(currentUsername) ? null : currentUsername; - if (currentUsername != null) { - data.put("blockedName", currentUsername); - } - } - if (newPunishmentType.isPermanentUntilSkinChange() && !data.containsKey("blockedSkin")) { - Object skinHash = player.getData() != null ? player.getData().get("lastSkinHash") : null; - if (skinHash instanceof String s) { - data.put("blockedSkin", s); - } - } - } + applyRestrictionBlocks(newPunishmentType, player, view); - String newCategory = statusCalculator.getEffectiveCategory(newPunishmentType, data); + String newCategory = statusCalculator.getEffectiveCategory(newPunishmentType, view); if (newCategory != null) { - data.put("enforcementCategory", newCategory); + view.setEnforcementCategory(newCategory); boolean hasExistingInCategory = player.getPunishments() .stream().anyMatch(existing -> { String existingCategory = statusCalculator.getEffectiveCategory(existing, types); @@ -236,79 +226,27 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR } boolean active = statusCalculator.isPunishmentActive(existing); - boolean unstarted = isUnstarted(existing); + boolean unstarted = existing.isUnstarted(); return active || unstarted; }); if (hasExistingInCategory) { - data.put("status", PunishmentStatus.UNSTARTED); + view.setStatus(PunishmentStatus.UNSTARTED); } } - String reqIssuerName = request.issuerId() != null ? null : request.issuerName(); + String reqIssuerName = PunishmentMapper.storedName(request.issuerId(), request.issuerName()); String reqIssuerId = request.issuerId(); - List notes = new ArrayList<>(); - String enforcementType = newPunishmentType != null && newPunishmentType.isKick() ? "kick" - : EnforcementCategory.BAN.name().equals(newCategory) ? "ban" - : EnforcementCategory.MUTE.name().equals(newCategory) ? "mute" - : "punishment"; - String issuedNote = calculatedDuration != null && calculatedDuration > 0 - ? "issued " + PunishmentMapper.formatDuration(calculatedDuration, false) + " " + enforcementType - : "issued permanent " + enforcementType; - if ("kick".equals(enforcementType)) { - issuedNote = "issued kick"; - } - notes.add(new PunishmentNote( - IdGenerator.generateShortId(), - issuedNote, - now, - reqIssuerName, - reqIssuerId - )); - if (request.reason() != null && !request.reason().isBlank()) { - notes.add(new PunishmentNote( - IdGenerator.generateShortId(), - request.reason(), - now, - reqIssuerName, - reqIssuerId - )); - } - if (request.notes() != null) { - for (CreateNoteRequest noteRequest : request.notes()) { - String noteIssuerId = noteRequest.issuerId() != null ? noteRequest.issuerId() : reqIssuerId; - String noteIssuerName = noteIssuerId != null ? null : (noteRequest.issuerName() != null ? noteRequest.issuerName() : request.issuerName()); - notes.add(new PunishmentNote(IdGenerator.generateShortId(), noteRequest.text(), now, noteIssuerName, noteIssuerId)); - } - } + List notes = buildCreationNotes(request, now, reqIssuerName, reqIssuerId, newPunishmentType, newCategory, calculatedDuration); - List evidence = new ArrayList<>(); - if (request.evidence() != null) { - for (CreateEvidenceRequest evidenceRequest : request.evidence()) { - String evIssuerId = evidenceRequest.issuerId() != null ? evidenceRequest.issuerId() : reqIssuerId; - String evIssuerName = evIssuerId != null ? null : (evidenceRequest.issuerName() != null ? evidenceRequest.issuerName() : request.issuerName()); - String type = evidenceRequest.type() != null ? evidenceRequest.type() : "text"; - SafeUrls.requireSafe(evidenceRequest.fileUrl(), "Invalid evidence URL"); - evidence.add(new PunishmentEvidence( - evidenceRequest.text(), - evidenceRequest.fileUrl(), - type, - evIssuerName, - evIssuerId, - now, - evidenceRequest.fileName(), - evidenceRequest.fileType(), - evidenceRequest.fileSize() - )); - } - } + List evidence = buildCreationEvidence(request, now, reqIssuerId); String punishmentId = IdGenerator.generateShortId(); - boolean pendingAcknowledgement = Boolean.TRUE.equals(data.remove("pendingAcknowledgement")); + boolean pendingAcknowledgement = view.removePendingAcknowledgement(); if (pendingAcknowledgement) { - data.put("status", PunishmentStatus.UNSTARTED); + view.setStatus(PunishmentStatus.UNSTARTED); } Date startedDate = null; @@ -362,42 +300,101 @@ public String createPunishment(Server server, UUID playerUuid, CreatePunishmentR return punishmentId; } - 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 applyRestrictionBlocks(PunishmentType punishmentType, Player player, PunishmentDataView data) { + if (punishmentType == null) { + return; + } + if (punishmentType.isPermanentUntilUsernameChange() && !data.hasBlockedName()) { + String currentUsername = PlayerDataUtils.extractLatestUsername(player.getUsernames()); + currentUsername = "Unknown".equals(currentUsername) ? null : currentUsername; + if (currentUsername != null) { + data.setBlockedName(currentUsername); + } + } + if (punishmentType.isPermanentUntilSkinChange() && !data.hasBlockedSkin()) { + String skinHash = player.data().lastSkinHash(); + if (skinHash != null) { + data.setBlockedSkin(skinHash); } } } - private boolean isUnstarted(Punishment punishment) { - Map data = punishment.getData(); - if (data == null) { - return false; + private List buildCreationNotes( + CreatePunishmentRequest request, + Date now, + String reqIssuerName, + String reqIssuerId, + PunishmentType punishmentType, + String newCategory, + Long calculatedDuration + ) { + List notes = new ArrayList<>(); + String enforcementType = punishmentType != null && punishmentType.isKick() ? "kick" + : EnforcementCategory.BAN.name().equals(newCategory) ? "ban" + : EnforcementCategory.MUTE.name().equals(newCategory) ? "mute" + : "punishment"; + String issuedNote = calculatedDuration != null && calculatedDuration > 0 + ? "issued " + PunishmentMapper.formatDuration(calculatedDuration, false) + " " + enforcementType + : "issued permanent " + enforcementType; + if ("kick".equals(enforcementType)) { + issuedNote = "issued kick"; } + notes.add(new PunishmentNote(IdGenerator.generateShortId(), issuedNote, now, reqIssuerName, reqIssuerId)); + if (request.reason() != null && !request.reason().isBlank()) { + notes.add(new PunishmentNote(IdGenerator.generateShortId(), request.reason(), now, reqIssuerName, reqIssuerId)); + } + if (request.notes() != null) { + for (CreateNoteRequest noteRequest : request.notes()) { + String noteIssuerId = noteRequest.issuerId() != null ? noteRequest.issuerId() : reqIssuerId; + String noteIssuerName = PunishmentMapper.storedName(noteIssuerId, noteRequest.issuerName() != null ? noteRequest.issuerName() : request.issuerName()); + notes.add(new PunishmentNote(IdGenerator.generateShortId(), noteRequest.text(), now, noteIssuerName, noteIssuerId)); + } + } + return notes; + } - String status = PunishmentData.getStatus(data); - if (!PunishmentStatus.UNSTARTED.equals(status)) { - return false; + private List buildCreationEvidence(CreatePunishmentRequest request, Date now, String reqIssuerId) { + List evidence = new ArrayList<>(); + if (request.evidence() != null) { + for (CreateEvidenceRequest evidenceRequest : request.evidence()) { + String evIssuerId = evidenceRequest.issuerId() != null ? evidenceRequest.issuerId() : reqIssuerId; + String evIssuerName = PunishmentMapper.storedName(evIssuerId, evidenceRequest.issuerName() != null ? evidenceRequest.issuerName() : request.issuerName()); + String type = evidenceRequest.type() != null ? evidenceRequest.type() : "text"; + SafeUrls.requireSafe(evidenceRequest.fileUrl(), "Invalid evidence URL"); + evidence.add(new PunishmentEvidence( + evidenceRequest.text(), + evidenceRequest.fileUrl(), + type, + evIssuerName, + evIssuerId, + now, + evidenceRequest.fileName(), + evidenceRequest.fileType(), + evidenceRequest.fileSize() + )); + } } + return evidence; + } - for (PunishmentModification mod : punishment.getModifications()) { - if (PunishmentModificationType.isPardon(mod.type())) { - return false; + 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); } } - return true; } + public PunishmentOperationResult acknowledgePunishment(Server server, UUID playerUuid, String punishmentId) { Player player = playerRepository.findByMinecraftUuid(server, playerUuid.toString()).orElse(null); if (player == null) { return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Player not found: " + playerUuid, false, 0); } - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); if (punishment == null) { return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found: " + punishmentId + " for player: " + playerUuid, false, 0); @@ -412,10 +409,6 @@ public PunishmentOperationResult acknowledgePunishment(Server server, UUID playe return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Punishment acknowledged", true, 1); } - private Punishment findPunishment(Player player, String punishmentId) { - return PunishmentQueryService.findPunishment(player, punishmentId); - } - public PunishmentOperationResult pardonPunishment(Server server, String punishmentId, String issuerName, String issuerId, String reason) { PunishmentContext context = punishmentQueryService.findPunishmentContext(server, punishmentId).orElse(null); if (context == null) { @@ -423,7 +416,7 @@ public PunishmentOperationResult pardonPunishment(Server server, String punishme } Punishment punishment = context.punishment(); - if (isPardoned(punishment)) { + if (punishment.isPardoned()) { return new PunishmentOperationResult(PunishmentOperationStatus.NO_OP, "Punishment has already been pardoned", false, 0); } @@ -431,7 +424,7 @@ public PunishmentOperationResult pardonPunishment(Server server, String punishme applyManualPardon(server, context.player(), punishment, issuerName, issuerId, reason); realtimePublisher.punishmentModified(server, context.player(), punishment); - if (PunishmentData.isAltBlocking(punishment.getData())) { + if (punishment.data().altBlocking()) { cascadePardonLinkedBans(server, punishmentId); } @@ -446,7 +439,7 @@ public int pardonPunishments(Server server, Player player, List targ for (Punishment punishment : targets) { applyManualPardon(server, player, punishment, issuerName, issuerId, reason); modified.add(new PunishmentRealtimePublisher.PlayerPunishment(player, punishment)); - if (PunishmentData.isAltBlocking(punishment.getData())) { + if (punishment.data().altBlocking()) { altBlockingParents.add(punishment.getId()); } } @@ -462,7 +455,7 @@ public int pardonPunishments(Server server, Player player, List targ private void applyManualPardon(Server server, Player player, Punishment punishment, String issuerName, String issuerId, String reason) { - String resolvedIssuerName = issuerId != null ? null : issuerName; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); Date now = new Date(); PunishmentModification modification = new PunishmentModification( @@ -485,22 +478,38 @@ private void applyManualPardon(Server server, Player player, Punishment punishme punishment.getModifications().add(modification); punishment.getNotes().addAll(notes); - punishment.getData().put("status", PunishmentStatus.PARDONED); + punishment.data().setStatus(PunishmentStatus.PARDONED); punishmentRepository.appendPardon(server, player.getMinecraftUuid().toString(), punishment.getId(), modification, notes, PunishmentStatus.PARDONED); } - public int cascadePardonLinkedBans(Server server, String parentPunishmentId) { + private int cascadeToLinkedBans(Server server, String parentPunishmentId, LinkedBanCascade cascade) { List modified = new ArrayList<>(); int count = 0; for (Player player : punishmentRepository.findByLinkedBanId(server, parentPunishmentId)) { - count += applyLinkedBanSystemPardon(server, player, parentPunishmentId, modified); + count += cascade.apply(player, modified); } realtimePublisher.punishmentsModified(server, modified); return count; } + @FunctionalInterface + private interface LinkedBanCascade { + int apply(Player player, List modified); + } + + private boolean isActiveLinkedBanFor(Punishment punishment, String parentPunishmentId) { + return punishment.getTypeOrdinal() == Punishment.LINKED_BAN_TYPE_ORDINAL + && parentPunishmentId.equals(punishment.data().linkedBanId()) + && statusCalculator.isPunishmentActive(punishment); + } + + public int cascadePardonLinkedBans(Server server, String parentPunishmentId) { + return cascadeToLinkedBans(server, parentPunishmentId, + (player, modified) -> applyLinkedBanSystemPardon(server, player, parentPunishmentId, modified)); + } + private int applyLinkedBanSystemPardon( Server server, Player player, @@ -510,10 +519,7 @@ private int applyLinkedBanSystemPardon( int count = 0; for (Punishment punishment : player.getPunishments()) { - if (punishment.getTypeOrdinal() != Punishment.LINKED_BAN_TYPE_ORDINAL - || punishment.getData() == null - || !parentPunishmentId.equals(punishment.getData().get("linkedBanId")) - || !statusCalculator.isPunishmentActive(punishment)) { + if (!isActiveLinkedBanFor(punishment, parentPunishmentId)) { continue; } @@ -531,7 +537,7 @@ private void applySystemPardon(Server server, String playerUuid, Punishment puni IdGenerator.generateShortId(), PunishmentModificationType.SYSTEM_PARDON.name(), now, - "System", + SYSTEM_ISSUER, null, reason, null, @@ -542,37 +548,22 @@ private void applySystemPardon(Server server, String playerUuid, Punishment puni IdGenerator.generateShortId(), reason, now, - "System", + SYSTEM_ISSUER, null ); punishment.getModifications().add(modification); punishment.getNotes().add(note); - if (punishment.getData() != null) { - punishment.getData().put("status", PunishmentStatus.PARDONED); - } + punishment.data().setStatus(PunishmentStatus.PARDONED); punishmentRepository.appendPardon(server, playerUuid, punishment.getId(), modification, List.of(note), PunishmentStatus.PARDONED); } - private boolean isPardoned(Punishment punishment) { - return punishment.getModifications() - .stream() - .anyMatch(modification -> - PunishmentModificationType.isPardon(modification.type())); - } public int cascadeDurationChangeToLinkedBans(Server server, String parentPunishmentId, Long newDuration, String issuerName) { - int count = 0; - List modified = new ArrayList<>(); - - for (Player player : punishmentRepository.findByLinkedBanId(server, parentPunishmentId)) { - count += applyLinkedBanDurationChange(server, player, parentPunishmentId, newDuration, modified); - } - - realtimePublisher.punishmentsModified(server, modified); - return count; + return cascadeToLinkedBans(server, parentPunishmentId, + (player, modified) -> applyLinkedBanDurationChange(server, player, parentPunishmentId, newDuration, modified)); } private int applyLinkedBanDurationChange( @@ -585,10 +576,7 @@ private int applyLinkedBanDurationChange( int count = 0; for (Punishment punishment : player.getPunishments()) { - if (punishment.getTypeOrdinal() != Punishment.LINKED_BAN_TYPE_ORDINAL - || punishment.getData() == null - || !parentPunishmentId.equals(punishment.getData().get("linkedBanId")) - || !statusCalculator.isPunishmentActive(punishment)) { + if (!isActiveLinkedBanFor(punishment, parentPunishmentId)) { continue; } @@ -598,7 +586,7 @@ private int applyLinkedBanDurationChange( IdGenerator.generateShortId(), PunishmentModificationType.MANUAL_DURATION_CHANGE.name(), now, - "System", + SYSTEM_ISSUER, null, "Cascaded from parent ban duration change", effective, @@ -609,11 +597,11 @@ private int applyLinkedBanDurationChange( IdGenerator.generateShortId(), "Duration changed (cascaded from parent ban)", now, - "System", + SYSTEM_ISSUER, null ); - punishment.getData().put("duration", effective); + punishment.data().setDuration(effective); punishment.getModifications().add(modification); punishment.getNotes().add(note); String linkedPlayerUuid = player.getMinecraftUuid().toString(); @@ -650,16 +638,14 @@ public List promoteUnstartedPunishments(Server server, Player player) { .stream() .filter(p -> { String effectiveCategory = statusCalculator.getEffectiveCategory(p, types); - return category.equals(effectiveCategory) && isUnstarted(p); + return category.equals(effectiveCategory) && p.isUnstarted(); }) .min((a, b) -> a.getIssued().compareTo(b.getIssued())); if (oldest.isPresent()) { Punishment toPromote = oldest.get(); punishmentRepository.unsetPunishmentStatus(server, player.getMinecraftUuid().toString(), toPromote.getId()); - if (toPromote.getData() != null) { - toPromote.getData().remove("status"); - } + toPromote.data().removeStatus(); promotedIds.add(toPromote.getId()); promoted.add(toPromote); } @@ -697,12 +683,7 @@ public List enforceAltBlockingBans(Server server, Player player) { continue; } - Map data = punishment.getData(); - if (data == null) { - continue; - } - - if (!PunishmentData.isAltBlocking(data)) { + if (!punishment.data().altBlocking()) { continue; } @@ -744,10 +725,11 @@ private String createLinkedBanPunishment(Server server, UUID playerUuid, String Date now = new Date(); Map data = new HashMap<>(); - data.put("linkedBanId", parentPunishmentId); - data.put("linkedBanParentUuid", parentPlayerUuid); + PunishmentDataView view = PunishmentDataView.ofMap(data); + view.setLinkedBanId(parentPunishmentId); + view.setLinkedBanParentUuid(parentPlayerUuid); if (duration != null) { - data.put("duration", duration); + view.setDuration(duration); } String punishmentId = IdGenerator.generateShortId(); @@ -760,21 +742,21 @@ private String createLinkedBanPunishment(Server server, UUID playerUuid, String IdGenerator.generateShortId(), linkedBanNote, now, - "System", + SYSTEM_ISSUER, null )); notes.add(new PunishmentNote( IdGenerator.generateShortId(), "Automatically issued linked ban due to alt-blocking ban on linked account", now, - "System", + SYSTEM_ISSUER, null )); Punishment punishment = new Punishment( punishmentId, Punishment.LINKED_BAN_TYPE_ORDINAL, - "System", + SYSTEM_ISSUER, null, now, now, @@ -791,19 +773,8 @@ private String createLinkedBanPunishment(Server server, UUID playerUuid, String return punishmentId; } - @SuppressWarnings("unchecked") private List getLinkedAccountUuids(Player player) { - if (player.getData() == null) { - return List.of(); - } - Object linkedObj = player.getData().get("linkedAccounts"); - if (linkedObj instanceof List list) { - return list.stream() - .filter(String.class::isInstance) - .map(String.class::cast) - .toList(); - } - return List.of(); + return player.data().linkedAccountUuids(); } public List checkRestrictionAutoPardons(Server server, Player player, String currentUsername, String currentSkinHash) { @@ -820,13 +791,13 @@ public List checkRestrictionAutoPardons(Server server, Player player, St continue; } - Map data = punishment.getData(); - if (data == null) { + PunishmentDataView data = punishment.data(); + if (data.asMap() == null) { continue; } if (type.isPermanentUntilUsernameChange() && currentUsername != null) { - String blockedName = PunishmentData.getBlockedName(data); + String blockedName = data.blockedName(); if (blockedName != null && !blockedName.equalsIgnoreCase(currentUsername)) { String reason = "Auto-pardoned: username changed from '" + blockedName + "' to '" + currentUsername + "'"; systemPardonPunishment(server, player.getMinecraftUuid(), punishment.getId(), reason); @@ -835,7 +806,7 @@ public List checkRestrictionAutoPardons(Server server, Player player, St } if (type.isPermanentUntilSkinChange() && currentSkinHash != null) { - String blockedSkin = PunishmentData.getBlockedSkin(data); + String blockedSkin = data.blockedSkin(); if (blockedSkin != null && !blockedSkin.equals(currentSkinHash)) { String reason = "Auto-pardoned: skin changed"; systemPardonPunishment(server, player.getMinecraftUuid(), punishment.getId(), reason); @@ -853,7 +824,7 @@ public void systemPardonPunishment(Server server, UUID playerUuid, String punish return; } - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); if (punishment == null) { return; } 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 9e6684b..88571e5 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentMapper.java @@ -1,14 +1,17 @@ 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.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentNote; +import gg.modl.backend.player.dto.response.PunishmentView; +import gg.modl.backend.player.dto.response.SimplePunishmentView; 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.Locale; import java.util.Map; import org.jetbrains.annotations.Nullable; @@ -16,31 +19,22 @@ public final class PunishmentMapper { private PunishmentMapper() {} - public static Map toPunishmentMap(Punishment punishment, List punishmentTypes) { - return toPunishmentMap(punishment, PunishmentTypeIndex.byOrdinal(punishmentTypes), Collections.emptyMap()); + public static PunishmentView toPunishmentView(Punishment punishment, List punishmentTypes) { + return toPunishmentView(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 PunishmentView toPunishmentView(Punishment punishment, List punishmentTypes, Map resolvedIssuers) { + return toPunishmentView(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()); - + public static PunishmentView toPunishmentView(Punishment punishment, Map typesByOrdinal, Map resolvedIssuers) { 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<>(); + Map punishmentData = punishment.data().asMap(); + Map dataWithTypeName = punishmentData != null ? + new LinkedHashMap<>(punishmentData) : new LinkedHashMap<>(); if (actualTypeName != null) { dataWithTypeName.put("typeName", actualTypeName); } @@ -57,7 +51,6 @@ public static Map toPunishmentMap(Punishment punishment, Map> notes = punishment.getNotes() .stream() @@ -69,7 +62,6 @@ public static Map toPunishmentMap(Punishment punishment, Map> evidence = punishment.getEvidence() .stream() @@ -85,12 +77,22 @@ public static Map toPunishmentMap(Punishment punishment, Map resolvedIssuers) { @@ -103,16 +105,27 @@ static String resolveIssuer(@Nullable String issuerId, @Nullable String issuerNa return issuerId != null ? "Unknown Staff" : "Console"; } - public static Map toSimplePunishment(Punishment punishment, List types, PlayerStatusCalculator statusCalculator) { + static String storedName(@Nullable String issuerId, @Nullable String issuerName) { + return issuerId != null ? null : issuerName; + } + + public static String offenseDisplayStatus(String offenseLevel) { + return switch (offenseLevel) { + case "first" -> "low"; + default -> offenseLevel; + }; + } + + public static SimplePunishmentView 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) { + public static SimplePunishmentView 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(); + public static SimplePunishmentView toSimplePunishment(Punishment punishment, Map typesByOrdinal, PlayerStatusCalculator statusCalculator, Map resolvedIssuers) { + PunishmentDataView data = punishment.data(); Date expires = statusCalculator.getEffectiveExpiry(punishment); PunishmentType punishmentType = typesByOrdinal.get(punishment.getTypeOrdinal()); @@ -123,7 +136,7 @@ public static Map toSimplePunishment(Punishment punishment, Map< String effectiveCategory = statusCalculator.getEffectiveCategory(punishmentType, data); String category = effectiveCategory != null ? effectiveCategory : "OTHER"; - String reason = PunishmentData.getReason(data); + String reason = data.reason(); if ((reason == null || reason.isBlank()) && punishment.getNotes() != null) { for (PunishmentNote note : punishment.getNotes()) { String noteText = note.text(); @@ -134,36 +147,37 @@ public static Map toSimplePunishment(Punishment punishment, Map< } } - 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; + List modifications = punishment.getModifications() + .stream() + .map(m -> new SimplePunishmentView.Modification( + m.type(), + m.date() != null ? m.date().getTime() : null, + m.effectiveDuration() != null ? m.effectiveDuration() : 0L, + resolveIssuer(m.issuerId(), m.issuerName(), resolvedIssuers) + )) + .toList(); + + return new SimplePunishmentView( + punishment.getId(), + typeName, + category, + punishment.getTypeOrdinal(), + punishment.getTypeOrdinal(), + punishment.getStarted() != null, + expires != null ? expires.getTime() : null, + reason != null && !reason.isBlank() ? reason : "No reason specified", + resolveIssuer(punishment.getIssuerId(), punishment.getIssuerName(), resolvedIssuers), + punishment.getIssued().getTime(), + playerDescription, + modifications + ); } public static boolean isAutoGeneratedNote(String noteText) { if (noteText == null) { return true; } - String lower = noteText.toLowerCase(); + String lower = noteText.toLowerCase(Locale.ROOT); return lower.equals("issued punishment") || lower.startsWith("issued ") || lower.equals("pardoned punishment") || @@ -188,10 +202,27 @@ public static String formatDuration(long durationMs, boolean isPermanent) { 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"); } + 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 1cab6b9..63dcd63 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentMutationService.java @@ -6,10 +6,10 @@ 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.data.punishment.PunishmentToggleOption; import gg.modl.backend.player.dto.request.AddModificationRequest; import gg.modl.backend.player.dto.request.ModifyPunishmentTicketsRequest; import gg.modl.backend.player.service.PunishmentQueryService.PunishmentContext; @@ -22,12 +22,12 @@ 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 gg.modl.backend.infrastructure.util.UuidUtils; import org.springframework.stereotype.Service; @Service @@ -49,7 +49,7 @@ public Player addModification(Server server, UUID playerUuid, String punishmentI .orElseThrow(() -> new ResourceNotFoundException("Player not found")); Date now = new Date(); - String modIssuerName = request.issuerId() != null ? null : request.issuerName(); + String modIssuerName = PunishmentMapper.storedName(request.issuerId(), request.issuerName()); String modIssuerId = request.issuerId(); PunishmentModification modification = new PunishmentModification( @@ -64,7 +64,7 @@ public Player addModification(Server server, UUID playerUuid, String punishmentI null ); - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.findPunishment(player, punishmentId); if (punishment == null) { throw new ResourceNotFoundException("Punishment not found"); } @@ -94,17 +94,13 @@ private void syncLinkedAppealOutcome(Server server, String punishmentId, AddModi 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; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); Punishment punishment = context.punishment(); Date now = new Date(); @@ -136,7 +132,7 @@ public PunishmentOperationResult changeDuration(Server server, String punishment punishment.getModifications().add(modification); punishment.getNotes().add(note); - punishment.getData().put("duration", effective); + punishment.data().setDuration(effective); String uuid = context.player().getMinecraftUuid().toString(); punishmentRepository.appendDurationChange(server, uuid, punishmentId, modification, note, effective); if (punishment.getStarted() == null) { @@ -145,7 +141,7 @@ public PunishmentOperationResult changeDuration(Server server, String punishment } realtimePublisher.punishmentModified(server, context.player(), punishment); - if (PunishmentData.isAltBlocking(punishment.getData())) { + if (punishment.data().altBlocking()) { int cascaded = punishmentLifecycleService.cascadeDurationChangeToLinkedBans(server, punishmentId, newDuration, issuerName); if (cascaded > 0) { return new PunishmentOperationResult( @@ -173,18 +169,18 @@ public PunishmentOperationResult toggleOption(Server server, String punishmentId Punishment punishment = context.punishment(); Date now = new Date(); - String resolvedIssuerName = issuerId != null ? null : issuerName; + String resolvedIssuerName = PunishmentMapper.storedName(issuerId, issuerName); PunishmentNote note = new PunishmentNote( IdGenerator.generateShortId(), - (enabled ? "enabled " : "disabled ") + toggleOption.displayName, + (enabled ? "enabled " : "disabled ") + toggleOption.displayName(), now, resolvedIssuerName, issuerId ); - punishment.getData().put(toggleOption.dataKey, enabled); + toggleOption.apply(punishment.data(), enabled); punishment.getNotes().add(note); punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, - Map.of(toggleOption.dataKey, enabled), note); + Map.of(toggleOption.dataKey(), enabled), note); realtimePublisher.punishmentModified(server, context.player(), punishment); return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Option toggled", true, 1); @@ -196,8 +192,7 @@ public PunishmentOperationResult acknowledgeStatWipe(Server server, String punis return new PunishmentOperationResult(PunishmentOperationStatus.NOT_FOUND, "Punishment not found", false, 0); } - Map data = context.punishment().getData(); - if (!PunishmentData.isWipeAfterExpiry(data)) { + if (!context.punishment().data().wipeAfterExpiry()) { return new PunishmentOperationResult( PunishmentOperationStatus.NO_OP, "Stat wipe no longer enabled for this punishment", @@ -206,8 +201,8 @@ public PunishmentOperationResult acknowledgeStatWipe(Server server, String punis ); } - punishmentRepository.setPunishmentData(server, context.player().getMinecraftUuid().toString(), punishmentId, - Map.of("statWipeCompleted", true, "statWipeCompletedAt", new Date()), null); + punishmentRepository.markStatWipeAcknowledged(server, context.player().getMinecraftUuid().toString(), + punishmentId, new Date()); return new PunishmentOperationResult(PunishmentOperationStatus.SUCCESS, "Stat wipe acknowledged", true, 1); } @@ -290,7 +285,7 @@ private void applyPunishmentTicketModifications(Server server, Player player, Pu if (request.modifyAssociatedTickets()) { String ticketIssuerName = issuerNameResolver.resolve(request.issuerId(), request.issuerName(), server); if (!addedIds.isEmpty()) { - closeAttachedTickets(server, addedIds, ticketIssuerName); + punishmentLifecycleService.closeAttachedTickets(server, addedIds, ticketIssuerName); } if (!removedIds.isEmpty()) { reopenAttachedTickets(server, removedIds, ticketIssuerName); @@ -298,16 +293,6 @@ private void applyPunishmentTicketModifications(Server server, Player player, Pu } } - 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 { @@ -320,55 +305,27 @@ private void reopenAttachedTickets(Server server, List ticketIds, String public void linkAppealToPunishment(Server server, String playerUuid, String punishmentId, String appealId, PunishmentNote note) { - punishmentRepository.linkAppealToPunishment(server, normalizeUuid(playerUuid), punishmentId, appealId, note); + punishmentRepository.linkAppealToPunishment(server, UuidUtils.normalize(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 recordAppealRejection(Server server, String playerUuid, String punishmentId, + PunishmentNote note, String appealTicketId) { + punishmentRepository.appendAppealResultNote(server, UuidUtils.normalize(playerUuid), punishmentId, + note, "Rejected", appealTicketId); } public void applyAppealApproval(Server server, String playerUuid, String punishmentId, PunishmentModification modification, PunishmentNote note, String appealOutcome, String appealTicketId) { - String normalizedUuid = normalizeUuid(playerUuid); + String normalizedUuid = UuidUtils.normalize(playerUuid); punishmentRepository.applyAppealApproval(server, normalizedUuid, punishmentId, modification, note, appealOutcome, appealTicketId); playerRepository.findByMinecraftUuid(server, normalizedUuid).ifPresent(player -> { - Punishment punishment = findPunishment(player, punishmentId); + Punishment punishment = PunishmentQueryService.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/player/service/PunishmentQueryService.java b/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java index 628a4f3..28c47ef 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentQueryService.java @@ -5,19 +5,22 @@ import gg.modl.backend.database.mongo.repository.TicketMongoRepository; 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.infrastructure.exception.ResourceNotFoundException; 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.PunishmentModification; -import gg.modl.backend.player.data.punishment.PunishmentData; +import gg.modl.backend.player.data.punishment.PunishmentDataView; import gg.modl.backend.player.data.punishment.PunishmentNote; import gg.modl.backend.player.data.punishment.PunishmentStatus; import gg.modl.backend.player.dto.response.PunishmentPreviewResponse; 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.dto.response.AppealEligibility; +import gg.modl.backend.player.dto.response.AppealInfoView; +import gg.modl.backend.player.dto.response.LinkedBanView; +import gg.modl.backend.player.dto.response.PunishmentView; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.DefaultPunishmentTypes; import gg.modl.backend.settings.data.DurationDetail; @@ -27,6 +30,7 @@ import gg.modl.backend.settings.service.OffenderThresholdSettingsService; import gg.modl.backend.settings.service.PunishmentTypeIndex; import gg.modl.backend.settings.service.PunishmentTypeService; +import gg.modl.backend.infrastructure.util.UuidUtils; import gg.modl.backend.storage.service.EvidenceUploadTokenService; import gg.modl.backend.ticket.data.Ticket; import gg.modl.backend.ticket.data.TicketStatus; @@ -34,7 +38,6 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; @@ -55,7 +58,6 @@ public class PunishmentQueryService { private final OffenderThresholdSettingsService thresholdSettingsService; private final EvidenceUploadTokenService evidenceUploadTokenService; private final IssuerNameResolver issuerNameResolver; - private final StaffMongoRepository staffRepository; private final TicketMongoRepository ticketRepository; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final int RECENT_PUNISHMENT_SCAN_LIMIT = 500; @@ -78,18 +80,18 @@ public List getPlayerPunishmentResponses(Server server, Play } private List toPunishmentResponses(Server server, List punishments) { - Map resolvedIssuers = resolveIssuersForPunishments(server, punishments); + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, punishments); return punishments.stream() .map(punishment -> buildPunishmentResponse(server, punishment, null, resolvedIssuers)) .toList(); } private PunishmentResponse toPunishmentResponseWithPlayer(Server server, Punishment punishment, Player player) { - return buildPunishmentResponse(server, punishment, player, resolveIssuersForPunishment(server, punishment)); + return buildPunishmentResponse(server, punishment, player, issuerNameResolver.resolveForPunishments(server, List.of(punishment))); } private PunishmentResponse buildPunishmentResponse(Server server, Punishment punishment, Player player, Map resolvedIssuers) { - Map data = punishment.getData(); + PunishmentDataView data = punishment.data(); boolean active = statusCalculator.isPunishmentActive(punishment); Date expires = statusCalculator.getEffectiveExpiry(punishment); @@ -109,15 +111,15 @@ private PunishmentResponse buildPunishmentResponse(Server server, Punishment pun punishment.getIssued(), punishment.getStarted(), punishmentTypeService.isAppealable(server, ordinal), - PunishmentData.getReason(data), - PunishmentData.getSeverity(data), - data != null ? resolveOffenderStatus(data) : null, + data.reason(), + data.severity(), + data.asMap() != null ? resolveOffenderStatus(data) : null, active, expires, playerUuid, playerUsername, - data != null ? PunishmentData.isAltBlocking(data) : null, - data != null ? PunishmentData.isWipeAfterExpiry(data) : null, + data.asMap() != null ? data.altBlocking() : null, + data.asMap() != null ? data.wipeAfterExpiry() : null, effectiveCategory, resolveModifications(punishment.getModifications(), resolvedIssuers), resolveNotes(punishment.getNotes(), resolvedIssuers), @@ -126,27 +128,18 @@ private PunishmentResponse buildPunishmentResponse(Server server, Punishment pun ); } - private Map resolveIssuersForPunishment(Server server, Punishment punishment) { - Set ids = collectIssuerIds(punishment); - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - - private static String resolveOffenderStatus(Map data) { - String status = PunishmentData.getStatus(data); + private static String resolveOffenderStatus(PunishmentDataView data) { + String status = data.status(); if (status != null && !PunishmentStatus.UNSTARTED.equals(status) && !PunishmentStatus.PARDONED.equals(status)) { return status; } - // Backward compat: map legacy offenseLevel to display status - String offenseLevel = PunishmentData.getOffenseLevel(data); + String offenseLevel = data.offenseLevel(); if (offenseLevel != null) { - return switch (offenseLevel.toLowerCase()) { + return switch (offenseLevel.toLowerCase(Locale.ROOT)) { case "first" -> "low"; - default -> offenseLevel; // "medium" and "habitual" stay as-is + default -> offenseLevel; }; } return null; @@ -199,14 +192,12 @@ private static List resolveEvidence(List )).toList(); } - public Optional> getMinecraftPunishmentById(Server server, String punishmentId) { + public Optional getMinecraftPunishmentById(Server server, String punishmentId) { return findPunishmentContext(server, punishmentId).map(context -> { List types = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuersForPunishment(server, context.punishment()); - Map result = PunishmentMapper.toPunishmentMap(context.punishment(), types, resolvedIssuers); - result.put("playerUuid", context.player().getMinecraftUuid().toString()); - result.put("playerName", getLatestUsername(context.player())); - return result; + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, List.of(context.punishment())); + return PunishmentMapper.toPunishmentView(context.punishment(), types, resolvedIssuers) + .withPlayer(context.player().getMinecraftUuid().toString(), getLatestUsername(context.player())); }); } @@ -255,12 +246,9 @@ public List searchPunishments(Server server, String sear boolean matches = punishment.getId().contains(searchQuery) || pattern.matcher(resolvedIssuerName).find(); - Map pData = punishment.getData(); - if (pData != null) { - String reason = PunishmentData.getReason(pData); - if (reason != null && pattern.matcher(reason).find()) { - matches = true; - } + String reason = punishment.data().reason(); + if (reason != null && pattern.matcher(reason).find()) { + matches = true; } if (matches) { @@ -282,7 +270,7 @@ public List searchPunishments(Server server, String sear return results; } - public List> getRecentPunishments(Server server, int hours) { + public List getRecentPunishments(Server server, int hours) { Date cutoff = new Date(System.currentTimeMillis() - (hours * 60L * 60L * 1000L)); List players = punishmentRepository.findWithPunishmentsIssuedAfter(server, cutoff, RECENT_PUNISHMENT_SCAN_LIMIT); List types = punishmentTypeService.getPunishmentTypes(server); @@ -299,31 +287,18 @@ public List> getRecentPunishments(Server server, int hours) } Map resolvedIssuers = issuerIds.isEmpty() ? Map.of() : issuerNameResolver.batchResolve(issuerIds, server); - List> punishments = new ArrayList<>(); + List punishments = new ArrayList<>(); for (PunishmentContext entry : recent) { - Map punishmentMap = PunishmentMapper.toPunishmentMap(entry.punishment(), types, resolvedIssuers); - punishmentMap.put("playerName", getLatestUsername(entry.player())); - punishmentMap.put("playerUuid", entry.player().getMinecraftUuid().toString()); - punishments.add(punishmentMap); + punishments.add(PunishmentMapper.toPunishmentView(entry.punishment(), types, resolvedIssuers) + .withPlayer(entry.player().getMinecraftUuid().toString(), getLatestUsername(entry.player()))); } - punishments.sort((left, right) -> ((Date) right.get("issued")).compareTo((Date) left.get("issued"))); + punishments.sort((left, right) -> right.issued().compareTo(left.issued())); return punishments.size() > 100 ? punishments.subList(0, 100) : punishments; } - private Map resolveIssuersForPunishments(Server server, List punishments) { - Set ids = new HashSet<>(); - for (Punishment p : punishments) { - ids.addAll(collectIssuerIds(p)); - } - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - - public List> getLinkedBansForParent(Server server, String parentPunishmentId) { - List> results = new ArrayList<>(); + public List getLinkedBansForParent(Server server, String parentPunishmentId) { + List results = new ArrayList<>(); List players = punishmentRepository.findByLinkedBanId(server, parentPunishmentId); for (Player player : players) { @@ -331,15 +306,14 @@ public List> getLinkedBansForParent(Server server, String pa for (Punishment punishment : player.getPunishments()) { if (punishment.getTypeOrdinal() == Punishment.LINKED_BAN_TYPE_ORDINAL && - punishment.getData() != null && - parentPunishmentId.equals(punishment.getData().get("linkedBanId"))) { - - Map entry = new LinkedHashMap<>(); - entry.put("punishmentId", punishment.getId()); - entry.put("playerUuid", player.getMinecraftUuid().toString()); - entry.put("playerName", username); - entry.put("active", statusCalculator.isPunishmentActive(punishment)); - results.add(entry); + parentPunishmentId.equals(punishment.data().linkedBanId())) { + + results.add(new LinkedBanView( + punishment.getId(), + player.getMinecraftUuid().toString(), + username, + statusCalculator.isPunishmentActive(punishment) + )); } } } @@ -348,7 +322,7 @@ public List> getLinkedBansForParent(Server server, String pa } public PunishmentPreviewView previewPunishment(Server server, String playerUuid, int typeOrdinal) { - Player player = playerRepository.findByMinecraftUuid(server, normalizeUuid(playerUuid)).orElse(null); + Player player = playerRepository.findByMinecraftUuid(server, UuidUtils.normalize(playerUuid)).orElse(null); if (player == null) { return PunishmentPreviewResponse.error("Player not found"); } @@ -368,10 +342,7 @@ public PunishmentPreviewView previewPunishment(Server server, String playerUuid, String socialOffenderLevel = thresholds.getSocialOffenderLevel(currentStatus.socialPoints()); String gameplayOffenderLevel = thresholds.getGameplayOffenderLevel(currentStatus.gameplayPoints()); - String displayStatus = switch (offenseLevel) { - case "first" -> "low"; - default -> offenseLevel; // "medium" and "habitual" stay as-is - }; + String displayStatus = PunishmentMapper.offenseDisplayStatus(offenseLevel); PunishmentPreviewResponse.PunishmentPreviewResponseBuilder builder = PunishmentPreviewResponse.builder() .status(200) @@ -452,7 +423,7 @@ public Optional createEvidenceUploadToken(Server server, String punishme )); } - public Optional> getPublicPunishmentWithAppealEligibility(Server server, String punishmentId) { + public Optional getPublicPunishmentWithAppealEligibility(Server server, String punishmentId) { PunishmentContext context = findPunishmentContext(server, punishmentId).orElse(null); if (context == null) { return Optional.empty(); @@ -461,18 +432,11 @@ public Optional> getPublicPunishmentWithAppealEligibility(Se PunishmentResponse punishment = toPunishmentResponseWithPlayer(server, context.punishment(), context.player()); if (punishment.started() == null) { - return Optional.of(Map.of("error", "This punishment has not been started yet and cannot be appealed at this time.")); + return Optional.of(new AppealEligibility.NotStarted( + "This punishment has not been started yet and cannot be appealed at this time.")); } - Map response = new HashMap<>(); - response.put("id", punishment.id()); - response.put("type", punishment.type()); - response.put("issued", punishment.issued()); - response.put("expires", punishment.expires()); - response.put("active", punishment.active()); - response.put("appealable", punishment.isAppealable()); - response.put("playerUuid", context.player().getMinecraftUuid().toString()); - + Map existingAppeal = null; List existingAppeals = ticketRepository.findAppealsByPunishmentId(server, punishmentId); if (!existingAppeals.isEmpty()) { Ticket latestAppeal = existingAppeals.stream() @@ -491,7 +455,7 @@ public Optional> getPublicPunishmentWithAppealEligibility(Se return leftDate.compareTo(rightDate); }) .orElse(existingAppeals.get(0)); - Map existingAppeal = new HashMap<>(); + existingAppeal = new HashMap<>(); existingAppeal.put("id", latestAppeal.getId()); existingAppeal.put("submittedDate", latestAppeal.getCreated()); existingAppeal.put("status", latestAppeal.getStatus() != null ? latestAppeal.getStatus().getId() : TicketStatus.OPEN.getId()); @@ -499,17 +463,25 @@ public Optional> getPublicPunishmentWithAppealEligibility(Se existingAppeal.put("appealWorkflowStatus", latestAppeal.getAppealWorkflowStatus().getId()); } existingAppeal.put("locked", latestAppeal.isLocked()); - response.put("existingAppeal", existingAppeal); } - Optional punishmentType = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.typeOrdinal()); - response.put("appealForm", punishmentType + Map appealForm = punishmentTypeService.getPunishmentTypeByOrdinal(server, punishment.typeOrdinal()) .map(PunishmentType::getAppealForm) .filter(form -> form.getFields() != null && !form.getFields().isEmpty()) .map(form -> OBJECT_MAPPER.convertValue(form, new TypeReference>() {})) - .orElse(null)); + .orElse(null); - return Optional.of(response); + return Optional.of(new AppealEligibility.Eligible(new AppealInfoView( + punishment.id(), + punishment.type(), + punishment.issued(), + punishment.expires(), + punishment.active(), + punishment.isAppealable(), + context.player().getMinecraftUuid().toString(), + existingAppeal, + appealForm + ))); } public PunishmentResponse getPunishmentById(Server server, String punishmentId) { @@ -549,8 +521,4 @@ public record PunishmentOperationResult( int affectedCount ) { } - - private static String normalizeUuid(String value) { - return value == null ? null : value.toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java b/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java index fbd7364..7712da8 100644 --- a/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java +++ b/src/main/java/gg/modl/backend/player/service/PunishmentRealtimePublisher.java @@ -2,6 +2,8 @@ import gg.modl.backend.player.data.Player; import gg.modl.backend.player.data.punishment.Punishment; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; import gg.modl.backend.realtime.publish.RealtimeEventPublisher; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; @@ -10,10 +12,8 @@ import gg.modl.proto.modl.v1.SyncModifiedPunishment; import gg.modl.proto.modl.v1.SyncPendingPunishment; import java.util.ArrayList; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -116,38 +116,30 @@ private SyncModifiedPunishment toModified(Server server, Player player, Punishme return syncProtoFactory.toModifiedPunishment(toEntry(server, player, punishment)); } - private Map toEntry(Server server, Player player, Punishment punishment) { + private SyncPunishmentEntry toEntry(Server server, Player player, Punishment punishment) { List types = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuers(server, punishment); - Map simplePunishment = + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, List.of(punishment)); + SimplePunishmentView simplePunishment = PunishmentMapper.toSimplePunishment(punishment, types, statusCalculator, resolvedIssuers); - return Map.of( - "minecraftUuid", player.getMinecraftUuid().toString(), - "username", PlayerDataUtils.extractLatestUsername(player.getUsernames()), - "punishment", simplePunishment + return new SyncPunishmentEntry( + player.getMinecraftUuid().toString(), + PlayerDataUtils.extractLatestUsername(player.getUsernames()), + simplePunishment ); } - private Map toEntry(Server server, EntryPunishment change) { + private SyncPunishmentEntry toEntry(Server server, EntryPunishment change) { List types = punishmentTypeService.getPunishmentTypes(server); - Map resolvedIssuers = resolveIssuers(server, change.punishment()); - Map simplePunishment = + Map resolvedIssuers = issuerNameResolver.resolveForPunishments(server, List.of(change.punishment())); + SimplePunishmentView simplePunishment = PunishmentMapper.toSimplePunishment(change.punishment(), types, statusCalculator, resolvedIssuers); - return Map.of( - "minecraftUuid", change.minecraftUuid(), - "username", change.username(), - "punishment", simplePunishment + return new SyncPunishmentEntry( + change.minecraftUuid(), + change.username(), + simplePunishment ); } - private Map resolveIssuers(Server server, Punishment punishment) { - Set ids = new HashSet<>(PunishmentQueryService.collectIssuerIds(punishment)); - if (ids.isEmpty()) { - return Map.of(); - } - return issuerNameResolver.batchResolve(ids, server); - } - public record PlayerPunishment(Player player, Punishment punishment) { } diff --git a/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java b/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java index b321843..4fdbd22 100644 --- a/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java +++ b/src/main/java/gg/modl/backend/player/service/SyncProtoFactory.java @@ -2,6 +2,10 @@ import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; import gg.modl.backend.player.controller.MinecraftPlayerProtoMapper; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncDataView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; +import gg.modl.backend.player.dto.response.SyncResult; import gg.modl.proto.modl.v1.SyncActiveStaffMember; import gg.modl.proto.modl.v1.SyncData; import gg.modl.proto.modl.v1.SyncMigrationTask; @@ -18,68 +22,71 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.function.Consumer; import java.util.function.LongConsumer; import org.springframework.stereotype.Component; @Component public class SyncProtoFactory { - public SyncResponse toSyncResponse(Map body) { + public SyncResponse toSyncResponse(SyncResult body) { return SyncResponse.newBuilder() - .setTimestamp(stringValue(body.get("timestamp"))) - .setData(toSyncData(map(body.get("data")))) + .setTimestamp(stringValue(body.timestamp())) + .setData(toSyncData(body.data())) .build(); } - public SyncData toSyncData(Map data) { + public SyncData toSyncData(SyncDataView data) { SyncData.Builder builder = SyncData.newBuilder(); - listOfMaps(data.get("pendingPunishments")).stream() + data.pendingPunishments().stream() .map(this::toPendingPunishment) .forEach(builder::addPendingPunishments); - listOfMaps(data.get("recentlyStartedPunishments")).stream() + data.recentlyStartedPunishments().stream() .map(this::toPendingPunishment) .forEach(builder::addRecentlyStartedPunishments); - listOfMaps(data.get("recentlyModifiedPunishments")).stream() + data.recentlyModifiedPunishments().stream() .map(this::toModifiedPunishment) .forEach(builder::addRecentlyModifiedPunishments); - listOfMaps(data.get("playerNotifications")).stream() + data.playerNotifications().stream() .map(this::toPlayerNotification) .forEach(builder::addPlayerNotifications); - listOfMaps(data.get("activeStaffMembers")).stream() + data.activeStaffMembers().stream() .map(this::toActiveStaffMember) .forEach(builder::addActiveStaffMembers); - listOfMaps(data.get("staffNotifications")).stream() + data.staffNotifications().stream() .map(this::toStaffNotification) .forEach(builder::addStaffNotifications); - listOfMaps(data.get("pendingStatWipes")).stream() + data.pendingStatWipes().stream() .map(this::toPendingStatWipe) .forEach(builder::addPendingStatWipes); - listOfMaps(data.get("staff2faVerifications")).stream() - .map(this::toStaff2faVerification) - .forEach(builder::addStaff2FaVerifications); + if (data.staff2faVerifications() != null) { + data.staff2faVerifications().stream() + .map(this::toStaff2faVerification) + .forEach(builder::addStaff2FaVerifications); + } - if (data.get("migrationTask") instanceof Map migrationTask) { - builder.setMigrationTask(toMigrationTask(stringObjectMap(migrationTask))); + if (data.migrationTask() != null) { + builder.setMigrationTask(toMigrationTask(data.migrationTask())); } - setOptionalLong(builder::setStaffPermissionsUpdatedAt, data.get("staffPermissionsUpdatedAt")); - setOptionalLong(builder::setPunishmentTypesUpdatedAt, data.get("punishmentTypesUpdatedAt")); + setOptionalLong(builder::setStaffPermissionsUpdatedAt, data.staffPermissionsUpdatedAt()); + setOptionalLong(builder::setPunishmentTypesUpdatedAt, data.punishmentTypesUpdatedAt()); return builder.build(); } - public SyncPendingPunishment toPendingPunishment(Map entry) { + public SyncPendingPunishment toPendingPunishment(SyncPunishmentEntry entry) { return SyncPendingPunishment.newBuilder() - .setMinecraftUuid(stringValue(entry.get("minecraftUuid"))) - .setUsername(stringValue(entry.get("username"))) - .setPunishment(MinecraftPlayerProtoMapper.toSimplePunishment(map(entry.get("punishment")))) + .setMinecraftUuid(stringValue(entry.minecraftUuid())) + .setUsername(stringValue(entry.username())) + .setPunishment(MinecraftPlayerProtoMapper.toSimplePunishment(entry.punishment())) .build(); } - public SyncModifiedPunishment toModifiedPunishment(Map entry) { + public SyncModifiedPunishment toModifiedPunishment(SyncPunishmentEntry entry) { return SyncModifiedPunishment.newBuilder() - .setMinecraftUuid(stringValue(entry.get("minecraftUuid"))) - .setUsername(stringValue(entry.get("username"))) - .setPunishment(toPunishmentWithModifications(map(entry.get("punishment")))) + .setMinecraftUuid(stringValue(entry.minecraftUuid())) + .setUsername(stringValue(entry.username())) + .setPunishment(toPunishmentWithModifications(entry.punishment())) .build(); } @@ -149,22 +156,25 @@ public SyncMigrationTask toMigrationTask(Map task) { .build(); } - private SyncPunishmentWithModifications toPunishmentWithModifications(Map punishment) { - SyncPunishmentWithModifications.Builder builder = SyncPunishmentWithModifications.newBuilder() - .setId(stringValue(punishment.get("id"))); + private SyncPunishmentWithModifications toPunishmentWithModifications(SimplePunishmentView punishment) { + SyncPunishmentWithModifications.Builder builder = SyncPunishmentWithModifications.newBuilder(); + if (punishment == null) { + return builder.build(); + } - listOfMaps(punishment.get("modifications")).stream() + builder.setId(stringValue(punishment.id())); + punishment.modifications().stream() .map(this::toPunishmentModification) .forEach(builder::addModifications); return builder.build(); } - private SyncPunishmentModification toPunishmentModification(Map modification) { + private SyncPunishmentModification toPunishmentModification(SimplePunishmentView.Modification modification) { SyncPunishmentModification.Builder builder = SyncPunishmentModification.newBuilder() - .setType(stringValue(modification.get("type"))); + .setType(stringValue(modification.type())); - setOptionalLong(builder::setTimestamp, modification.get("timestamp")); - setOptionalLong(builder::setEffectiveDuration, modification.get("effectiveDuration")); + setOptionalLong(builder::setTimestamp, modification.timestamp()); + setOptionalLong(builder::setEffectiveDuration, modification.effectiveDuration()); return builder.build(); } @@ -175,24 +185,6 @@ private static List list(Object object) { return List.of(); } - private static List> listOfMaps(Object object) { - return list(object).stream() - .filter(Map.class::isInstance) - .map(value -> { - @SuppressWarnings("unchecked") - Map map = (Map) value; - return map; - }) - .toList(); - } - - private static Map map(Object object) { - if (object instanceof Map rawMap) { - return stringObjectMap(rawMap); - } - return Map.of(); - } - private static Map stringObjectMap(Map rawMap) { Map result = new LinkedHashMap<>(); rawMap.forEach((key, value) -> result.put(Objects.toString(key), value)); @@ -213,7 +205,7 @@ private static long longValue(Object value) { return 0L; } - private static void setOptionalString(java.util.function.Consumer setter, Object value) { + private static void setOptionalString(Consumer setter, Object value) { if (value != null) { setter.accept(Objects.toString(value)); } @@ -225,7 +217,7 @@ private static void setOptionalLong(LongConsumer setter, Object value) { } } - private static void setOptionalBoolean(java.util.function.Consumer setter, Object value) { + private static void setOptionalBoolean(Consumer setter, Object value) { if (value instanceof Boolean bool) { setter.accept(bool); } diff --git a/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java b/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java index ee98a3b..bdb2f6f 100644 --- a/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java +++ b/src/main/java/gg/modl/backend/player/service/SyncStaffEventService.java @@ -2,11 +2,13 @@ import gg.modl.backend.database.mongo.repository.PunishmentMongoRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; -import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.service.PanelDomainResolver; 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.data.punishment.PunishmentModificationType; +import gg.modl.backend.player.dto.response.SimplePunishmentView; +import gg.modl.backend.player.dto.response.SyncPunishmentEntry; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.service.PunishmentTypeIndex; @@ -31,14 +33,14 @@ public class SyncStaffEventService { private final TicketMongoRepository ticketRepository; private final PunishmentMongoRepository punishmentRepository; private final IssuerNameResolver issuerNameResolver; - private final ModlProperties modlProperties; + private final PanelDomainResolver panelDomainResolver; private final PlayerStatusCalculator statusCalculator; public List> collectStaffEvents( Server server, Instant lastSync, List types, - List> recentlyModifiedPunishments + List recentlyModifiedPunishments ) { List> notifications = new ArrayList<>(); collectTicketNotifications(server, lastSync, notifications); @@ -102,10 +104,7 @@ private void collectTicketNotifications(Server server, Instant lastSync, List> recentlyModifiedPunishments, List> notifications) { + private void collectPardonNotifications(List recentlyModifiedPunishments, List> notifications) { try { - for (Map modified : recentlyModifiedPunishments) { - if (!(modified.get("punishment") instanceof Map rawPunishment)) { - continue; - } - Map punishment = (Map) rawPunishment; - String username = modified.get("username") instanceof String value ? value : "Unknown"; + for (SyncPunishmentEntry modified : recentlyModifiedPunishments) { + SimplePunishmentView punishment = modified.punishment(); + String username = modified.username() != null ? modified.username() : "Unknown"; - if (!(punishment.get("modifications") instanceof List rawModifications)) { - continue; - } - - for (Object rawModification : rawModifications) { - if (!(rawModification instanceof Map rawModificationMap)) { + for (SimplePunishmentView.Modification modification : punishment.modifications()) { + if (!PunishmentModificationType.isPardon(modification.type())) { continue; } - Map modification = (Map) rawModificationMap; - String type = modification.get("type") instanceof String value ? value : null; - if (PunishmentModificationType.isPardon(type)) { - String pardoner = modification.get("issuerName") instanceof String value ? value : "System"; - String punishmentType = punishment.get("type") instanceof String value ? value : "punishment"; - Object timestamp = modification.get("timestamp"); - Map notification = new LinkedHashMap<>(); - notification.put("id", "pardon_" + punishment.get("id")); - notification.put("type", "PUNISHMENT_PARDONED"); - notification.put("message", pardoner + ": pardoned " + username + "'s " + punishmentType); - notification.put("timestamp", timestamp != null ? timestamp : System.currentTimeMillis()); - notifications.add(notification); - } + String pardoner = modification.issuerName() != null ? modification.issuerName() : "System"; + String punishmentType = punishment.type() != null ? punishment.type() : "punishment"; + Long timestamp = modification.timestamp(); + Map notification = new LinkedHashMap<>(); + notification.put("id", "pardon_" + punishment.id()); + notification.put("type", "PUNISHMENT_PARDONED"); + notification.put("message", pardoner + ": pardoned " + username + "'s " + punishmentType); + notification.put("timestamp", timestamp != null ? timestamp : System.currentTimeMillis()); + notifications.add(notification); } } } catch (Exception e) { 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 0b72e20..4b07707 100644 --- a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java +++ b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentController.java @@ -1,6 +1,7 @@ package gg.modl.backend.punishment.controller; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; +import gg.modl.backend.player.dto.response.AppealEligibility; import gg.modl.backend.player.service.PunishmentQueryService; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; @@ -27,12 +28,14 @@ public ResponseEntity getAppealInfo( ) { Server server = RequestUtil.getRequestServer(request); - Map result = punishmentQueryService.getPublicPunishmentWithAppealEligibility(server, punishmentId) + AppealEligibility eligibility = 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)); + return switch (eligibility) { + case AppealEligibility.NotStarted notStarted -> + ResponseEntity.badRequest().body(Map.of("error", notStarted.message())); + case AppealEligibility.Eligible eligible -> + ResponseEntity.ok(PublicPunishmentProtoMapper.toAppealInfo(eligible.info())); + }; } } diff --git a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java index 06b6706..02cca41 100644 --- a/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java +++ b/src/main/java/gg/modl/backend/punishment/controller/PublicPunishmentProtoMapper.java @@ -1,52 +1,41 @@ package gg.modl.backend.punishment.controller; import gg.modl.backend.infrastructure.proto.ProtoMapperSupport; +import gg.modl.backend.player.dto.response.AppealInfoView; import gg.modl.proto.modl.v1.PublicPunishmentAppealInfoResponse; import java.util.Date; -import java.util.Map; -import java.util.Objects; final class PublicPunishmentProtoMapper { private PublicPunishmentProtoMapper() { } - static PublicPunishmentAppealInfoResponse toAppealInfo(Map appealInfo) { + static PublicPunishmentAppealInfoResponse toAppealInfo(AppealInfoView appealInfo) { PublicPunishmentAppealInfoResponse.Builder builder = PublicPunishmentAppealInfoResponse.newBuilder() - .setId(string(appealInfo.get("id"))) - .setType(string(appealInfo.get("type"))) - .setActive(Boolean.TRUE.equals(appealInfo.get("active"))) - .setAppealable(Boolean.TRUE.equals(appealInfo.get("appealable"))) - .setPlayerUuid(string(appealInfo.get("playerUuid"))); + .setId(ProtoMapperSupport.stringValue(appealInfo.id())) + .setType(ProtoMapperSupport.stringValue(appealInfo.type())) + .setActive(appealInfo.active()) + .setAppealable(appealInfo.appealable()) + .setPlayerUuid(ProtoMapperSupport.stringValue(appealInfo.playerUuid())); - Long issued = epochMillis(appealInfo.get("issued")); + Long issued = epochMillis(appealInfo.issued()); if (issued != null) { builder.setIssued(issued); } - Long expires = epochMillis(appealInfo.get("expires")); + Long expires = epochMillis(appealInfo.expires()); if (expires != null) { builder.setExpires(expires); } - if (appealInfo.get("existingAppeal") instanceof Map existingAppeal) { - builder.setExistingAppeal(ProtoMapperSupport.legacyStruct(ProtoMapperSupport.stringObjectMap(existingAppeal))); + if (appealInfo.existingAppeal() != null) { + builder.setExistingAppeal(ProtoMapperSupport.legacyStruct(appealInfo.existingAppeal())); } - if (appealInfo.get("appealForm") instanceof Map appealForm) { - builder.setAppealForm(ProtoMapperSupport.legacyStruct(ProtoMapperSupport.stringObjectMap(appealForm))); + if (appealInfo.appealForm() != null) { + builder.setAppealForm(ProtoMapperSupport.legacyStruct(appealInfo.appealForm())); } return builder.build(); } - private static Long epochMillis(Object value) { - if (value instanceof Date date) { - return date.getTime(); - } - if (value instanceof Number number) { - return number.longValue(); - } - return null; - } - - private static String string(Object value) { - return value == null ? "" : Objects.toString(value); + private static Long epochMillis(Date value) { + return value != null ? value.getTime() : null; } } diff --git a/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java b/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java index 6e7c44f..6099123 100644 --- a/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java +++ b/src/main/java/gg/modl/backend/realtime/auth/RealtimeTopicAuthorizer.java @@ -3,7 +3,7 @@ import gg.modl.backend.role.service.PermissionService; 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.staff.service.StaffLookupCache; import gg.modl.proto.modl.v1.Topic; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -13,7 +13,7 @@ @RequiredArgsConstructor public class RealtimeTopicAuthorizer { private final PermissionService permissionService; - private final StaffService staffService; + private final StaffLookupCache staffLookupCache; public boolean canSubscribe(RealtimePrincipal principal, Topic topic) { if (topic == null || topic == Topic.TOPIC_UNSPECIFIED) { @@ -57,7 +57,7 @@ private boolean hasPanelPermission(RealtimePrincipal principal, String permissio return true; } - Optional staffOpt = staffService.getStaffByEmail(server, email); + Optional staffOpt = staffLookupCache.findByEmail(server, email); String roleId = staffOpt.map(Staff::getRoleId).orElse(null); return roleId != null && permissionService.hasPermission(server, roleId, permission); } diff --git a/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java b/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java index 6bf8dd1..4b057ec 100644 --- a/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java +++ b/src/main/java/gg/modl/backend/realtime/publish/RealtimeEventPublisher.java @@ -29,6 +29,10 @@ @RequiredArgsConstructor @Slf4j public class RealtimeEventPublisher { + private static final String PUNISHMENT_KEY_INFIX = "::pun::"; + private static final String STAFF_2FA_KEY_INFIX = "::2fa::"; + private static final String KEY_FIELD_SEPARATOR = "::"; + private final RealtimeEventDispatcher dispatcher; public void invalidatePanel(Server server, PanelResource resource) { @@ -113,11 +117,11 @@ private static String punishmentEventId(String serverId, List modified) { if (pending.size() == 1 && modified.isEmpty()) { var punishment = pending.get(0).getPunishment(); - return serverId + "::pun::" + punishment.getId() + "::" + punishment.getIssuedAt(); + return serverId + PUNISHMENT_KEY_INFIX + punishment.getId() + KEY_FIELD_SEPARATOR + punishment.getIssuedAt(); } if (modified.size() == 1 && pending.isEmpty()) { var punishment = modified.get(0).getPunishment(); - return serverId + "::pun::" + punishment.getId() + "::" + punishment.getModificationsCount(); + return serverId + PUNISHMENT_KEY_INFIX + punishment.getId() + KEY_FIELD_SEPARATOR + punishment.getModificationsCount(); } return null; } @@ -128,6 +132,6 @@ private static String staff2faEventId(String serverId, List= properties.getMaxUnauthenticatedConnections()) { - return Admission.REJECTED_GLOBAL; - } int current = perIpCounts.getOrDefault(ip, 0); if (current >= properties.getMaxUnauthenticatedConnectionsPerIp()) { return Admission.REJECTED_PER_IP; } + if (totalCount >= properties.getMaxUnauthenticatedConnections()) { + return Admission.REJECTED_GLOBAL; + } totalCount++; perIpCounts.put(ip, current + 1); return Admission.ADMITTED; diff --git a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java index 14a2e83..0503031 100644 --- a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java +++ b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionRegistry.java @@ -95,15 +95,6 @@ private static RealtimeConnectionSnapshot toSnapshot(String sessionId, Connectio return new RealtimeConnectionSnapshot(sessionId, entry.session(), entry.state()); } - public void removeClosedSessions() { - connections.forEach((sessionId, entry) -> { - if (!entry.session().isOpen()) { - connections.remove(sessionId, entry); - pruneServerIndex(sessionId, entry); - } - }); - } - private void pruneServerIndex(String sessionId, ConnectionEntry entry) { String serverId = entry.state().getServerId(); if (serverId == null) { diff --git a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java index e392431..dc1d397 100644 --- a/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java +++ b/src/main/java/gg/modl/backend/realtime/state/RealtimeConnectionState.java @@ -65,7 +65,7 @@ public RealtimePrincipal getPrincipal() { } public RealtimeClientKind getClientKind() { - return principal.clientKind(); + return principal != null ? principal.clientKind() : null; } @Nullable @@ -78,15 +78,6 @@ public String getServerId() { return principal != null ? principal.serverId() : null; } - @Nullable - public String getUserEmail() { - return principal != null ? principal.email() : null; - } - - public int getProtocolVersion() { - return protocolVersion; - } - public Set getSubscriptions() { synchronized (subscriptions) { return Set.copyOf(subscriptions); @@ -119,11 +110,6 @@ public void recordHeartbeat() { lastHeartbeat = Instant.now(); } - @Nullable - public String getLastAcknowledgedEventId() { - return lastAcknowledgedEventId; - } - public void setLastAcknowledgedEventId(@Nullable String lastAcknowledgedEventId) { this.lastAcknowledgedEventId = lastAcknowledgedEventId; } @@ -139,12 +125,4 @@ public long recordDeliveryAttempt() { public long recordDeliveryFailure() { return deliveryFailures.incrementAndGet(); } - - public long getDeliveryAttempts() { - return deliveryAttempts.get(); - } - - public long getDeliveryFailures() { - return deliveryFailures.get(); - } } diff --git a/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java b/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java index 41279df..8ed1982 100644 --- a/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java +++ b/src/main/java/gg/modl/backend/realtime/transport/RealtimeCodec.java @@ -4,7 +4,6 @@ import com.google.protobuf.Timestamp; import gg.modl.backend.realtime.config.RealtimeProperties; import gg.modl.proto.modl.v1.ErrorCode; -import gg.modl.proto.modl.v1.Heartbeat; import gg.modl.proto.modl.v1.RealtimeEnvelope; import gg.modl.proto.modl.v1.ReconnectAction; import gg.modl.proto.modl.v1.ReconnectAdvice; @@ -45,13 +44,6 @@ public BinaryMessage serverHello(String connectionId, Collection accepted return toMessage(baseEnvelope().setServerHello(hello).build()); } - public BinaryMessage heartbeat(long sequence) { - Heartbeat heartbeat = Heartbeat.newBuilder() - .setSequence(sequence) - .build(); - return toMessage(baseEnvelope().setHeartbeat(heartbeat).build()); - } - public BinaryMessage error(ErrorCode code, String message) { gg.modl.proto.modl.v1.Error error = gg.modl.proto.modl.v1.Error.newBuilder() .setCode(code) @@ -70,7 +62,7 @@ public BinaryMessage deployDrainAdvice() { return toMessage(baseEnvelope().setReconnectAdvice(advice).build()); } - public RealtimeEnvelope buildOutboundEnvelope(RealtimeEnvelope envelope) { + private RealtimeEnvelope buildOutboundEnvelope(RealtimeEnvelope envelope) { RealtimeEnvelope.Builder builder = envelope.toBuilder(); if (builder.getEventId().isBlank()) { builder.setEventId(UUID.randomUUID().toString()); @@ -88,10 +80,6 @@ public RealtimeEnvelope buildOutboundEnvelope(RealtimeEnvelope envelope) { return builder.build(); } - public BinaryMessage outbound(RealtimeEnvelope envelope) { - return toMessage(buildOutboundEnvelope(envelope)); - } - public byte[] outboundPayload(RealtimeEnvelope envelope) { RealtimeEnvelope outboundEnvelope = buildOutboundEnvelope(envelope); byte[] payload = outboundEnvelope.toByteArray(); diff --git a/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java b/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java index 70c573c..9a9aa8b 100644 --- a/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java +++ b/src/main/java/gg/modl/backend/realtime/transport/RealtimeWebSocketHandler.java @@ -195,9 +195,7 @@ private void handleAuthenticatedMessage(WebSocketSession session, RealtimeConnec } switch (envelope.getPayloadCase()) { - case HEARTBEAT -> { - state.recordHeartbeat(); - } + case HEARTBEAT -> state.recordHeartbeat(); case ACK -> { state.setLastAcknowledgedEventId(envelope.getAck().getEventId()); metrics.recordAck(state, envelope.getAck().getEventId()); diff --git a/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java b/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java index 599fc2e..dc4c31b 100644 --- a/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java +++ b/src/main/java/gg/modl/backend/registration/PublicRegistrationController.java @@ -21,7 +21,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Date; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; @@ -34,20 +33,33 @@ @RestController @RequestMapping(RESTMappingV1.PUBLIC_REGISTRATION) -@RequiredArgsConstructor @Profile("!staging") public class PublicRegistrationController { private static final String CLI_TURNSTILE_TOKEN_HEADER = "X-Turnstile-Token"; - @Value("${modl.registration.cli-turnstile-required:false}") - private boolean cliTurnstileRequired; - + private final boolean cliTurnstileRequired; private final ServerService serverService; private final SessionService sessionService; private final RegistrationService registrationService; private final CookieUtil cookieUtil; private final SubdomainValidator subdomainValidator; + public PublicRegistrationController( + @Value("${modl.registration.cli-turnstile-required:false}") boolean cliTurnstileRequired, + ServerService serverService, + SessionService sessionService, + RegistrationService registrationService, + CookieUtil cookieUtil, + SubdomainValidator subdomainValidator + ) { + this.cliTurnstileRequired = cliTurnstileRequired; + this.serverService = serverService; + this.sessionService = sessionService; + this.registrationService = registrationService; + this.cookieUtil = cookieUtil; + this.subdomainValidator = subdomainValidator; + } + @PostMapping public ResponseEntity register( HttpServletRequest request, diff --git a/src/main/java/gg/modl/backend/registration/RegistrationService.java b/src/main/java/gg/modl/backend/registration/RegistrationService.java index fa69b05..3de0ee3 100644 --- a/src/main/java/gg/modl/backend/registration/RegistrationService.java +++ b/src/main/java/gg/modl/backend/registration/RegistrationService.java @@ -4,6 +4,7 @@ import gg.modl.backend.infrastructure.config.ModlProperties; import gg.modl.backend.email.EmailHTMLTemplate; import gg.modl.backend.email.EmailService; +import gg.modl.backend.infrastructure.ratelimit.BucketPool; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.infrastructure.turnstile.TurnstileService; import gg.modl.backend.server.ServerService; @@ -11,9 +12,11 @@ import gg.modl.backend.server.data.Server; import gg.modl.backend.server.data.ServerPlan; import gg.modl.backend.settings.service.ApiKeySettingsService; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.ConsumptionProbe; +import java.time.Duration; import java.util.Date; import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -30,32 +33,32 @@ public class RegistrationService { private final ServerService serverService; private final TurnstileService turnstileService; private final SubdomainValidator subdomainValidator; + private final BucketPool bucketPool; private static final int TOKEN_BYTE_LENGTH = 32; - private static final long RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; - private static final long CLI_RATE_LIMIT_WINDOW_MS = 30 * 60 * 1000; + private static final int REGISTRATION_CAPACITY = 1; + private static final long NANOS_PER_MINUTE = 60_000_000_000L; + private static final Duration RATE_LIMIT_WINDOW = Duration.ofMinutes(10); + private static final Duration CLI_RATE_LIMIT_WINDOW = Duration.ofMinutes(30); private static final long AUTO_LOGIN_TOKEN_EXPIRY_MS = 10 * 60 * 1000; private static final ServerPlan PUBLIC_REGISTRATION_PLAN = ServerPlan.FREE; - private final ConcurrentHashMap rateLimitMap = new ConcurrentHashMap<>(); - private final ConcurrentHashMap cliRateLimitMap = new ConcurrentHashMap<>(); - private record RateLimitResult(boolean limited, long remainingMinutes) {} public enum RegistrationChannel { - WEB(RATE_LIMIT_WINDOW_MS, "You can only register one server every 10 minutes."), - CLI(CLI_RATE_LIMIT_WINDOW_MS, "CLI registration is limited to once every 30 minutes."); + WEB(RATE_LIMIT_WINDOW, "You can only register one server every 10 minutes."), + CLI(CLI_RATE_LIMIT_WINDOW, "CLI registration is limited to once every 30 minutes."); - private final long windowMs; + private final Duration window; private final String cooldownMessage; - RegistrationChannel(long windowMs, String cooldownMessage) { - this.windowMs = windowMs; + RegistrationChannel(Duration window, String cooldownMessage) { + this.window = window; this.cooldownMessage = cooldownMessage; } - long windowMs() { - return windowMs; + Duration window() { + return window; } String cooldownMessage() { @@ -156,35 +159,15 @@ private Optional rejectDuplicateEmail(ServerService.Serve return Optional.empty(); } - private ConcurrentHashMap store(RegistrationChannel channel) { - return channel == RegistrationChannel.CLI ? cliRateLimitMap : rateLimitMap; - } - private RateLimitResult reserve(RegistrationChannel channel, String clientIp) { - return reserve(store(channel), channel.windowMs(), clientIp); - } - - private void evictExpired(ConcurrentHashMap map, long windowMs) { - long now = System.currentTimeMillis(); - map.entrySet().removeIf(entry -> (now - entry.getValue()) >= windowMs); - } - - private RateLimitResult reserve(ConcurrentHashMap map, long windowMs, String clientIp) { - evictExpired(map, windowMs); - long now = System.currentTimeMillis(); - long[] prior = { -1L }; - map.compute(clientIp, (k, last) -> { - if (last != null && (now - last) < windowMs) { - prior[0] = last; - return last; - } - prior[0] = -1L; - return now; - }); - if (prior[0] != -1L) { - return new RateLimitResult(true, (windowMs - (now - prior[0])) / 1000 / 60 + 1); - } - return new RateLimitResult(false, 0); + Bucket bucket = bucketPool.resolveBucket( + "registration:" + channel.name(), clientIp, REGISTRATION_CAPACITY, channel.window()); + ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1); + if (probe.isConsumed()) { + return new RateLimitResult(false, 0); + } + long remainingMinutes = Math.max(1, (probe.getNanosToWaitForRefill() + NANOS_PER_MINUTE - 1) / NANOS_PER_MINUTE); + return new RateLimitResult(true, remainingMinutes); } public String generateToken() { diff --git a/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java b/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java index 1867b72..a0f48ef 100644 --- a/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java +++ b/src/main/java/gg/modl/backend/registration/cleanup/RegistrationCleanupService.java @@ -2,13 +2,13 @@ import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository; import gg.modl.backend.database.mongo.repository.ServerDatabaseMongoRepository.PlayerCollectionInspection; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerRegistrationCleanupRepository; import gg.modl.backend.registration.config.RegistrationCleanupProperties; import gg.modl.backend.role.service.PermissionService; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.ProvisioningStatus; import gg.modl.backend.server.data.Server; -import gg.modl.backend.staff.service.StaffService; +import gg.modl.backend.staff.service.StaffLookupCache; import java.time.Clock; import java.time.Instant; import java.util.Date; @@ -18,17 +18,18 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor @Slf4j public class RegistrationCleanupService { private final RegistrationCleanupProperties properties; - private final ServerMongoRepository serverRepository; + private final ServerRegistrationCleanupRepository serverRegistrationCleanupRepository; private final ServerDatabaseMongoRepository serverDatabaseRepository; private final ServerService serverService; private final PermissionService permissionService; - private final StaffService staffService; + private final StaffLookupCache staffLookupCache; private final Clock clock; @Scheduled(fixedDelayString = "${modl.registration.cleanup.interval-ms:3600000}") @@ -44,7 +45,7 @@ public void runCleanupOnce() { Instant now = clock.instant(); Date cutoff = Date.from(now.minus(properties.getExpiry())); Date claimCutoff = Date.from(now.minus(properties.getClaimTtl())); - List candidates = serverRepository.findExpiredRegistrationCleanupCandidates( + List candidates = serverRegistrationCleanupRepository.findExpiredRegistrationCleanupCandidates( cutoff, claimCutoff, properties.getBatchSize() @@ -85,14 +86,14 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I return; } - Optional claimed = serverRepository.claimExpiredRegistrationForCleanup(candidate.getId(), cutoff, claimCutoff, now); + Optional claimed = serverRegistrationCleanupRepository.claimExpiredRegistrationForCleanup(candidate.getId(), cutoff, claimCutoff, now); if (claimed.isEmpty()) { stats.skippedRace++; return; } Server claimedServer = claimed.get(); - if (!isStillEligible(claimedServer, cutoff) || isBlank(claimedServer.getCleanupClaimId())) { + if (!isStillEligible(claimedServer, cutoff) || !StringUtils.hasText(claimedServer.getCleanupClaimId())) { releaseClaimIfPresent(claimedServer); stats.skippedRace++; return; @@ -104,7 +105,7 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I return; } - Optional confirmed = serverRepository.confirmRegistrationCleanupClaim( + Optional confirmed = serverRegistrationCleanupRepository.confirmRegistrationCleanupClaim( claimedServer.getId(), claimedServer.getCleanupClaimId(), cutoff, @@ -121,7 +122,7 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I return; } - boolean deleted = serverRepository.deleteClaimedExpiredRegistration( + boolean deleted = serverRegistrationCleanupRepository.deleteClaimedExpiredRegistration( confirmedServer.getId(), confirmedServer.getCleanupClaimId(), cutoff @@ -133,13 +134,13 @@ private void processCandidate(Server candidate, Date cutoff, Date claimCutoff, I serverService.evictAllServerCaches(); permissionService.evictPermissionCache(); - staffService.evictAllStaffCaches(); + staffLookupCache.evictAll(); stats.deleted++; } private void releaseClaimIfPresent(Server server) { - if (!isBlank(server.getCleanupClaimId())) { - serverRepository.releaseRegistrationCleanupClaim(server.getId(), server.getCleanupClaimId()); + if (StringUtils.hasText(server.getCleanupClaimId())) { + serverRegistrationCleanupRepository.releaseRegistrationCleanupClaim(server.getId(), server.getCleanupClaimId()); } } @@ -159,16 +160,16 @@ private boolean isStillEligible(Server server, Date cutoff) { return server != null && Boolean.FALSE.equals(server.getEmailVerified()) && server.getProvisioningStatus() == ProvisioningStatus.PENDING - && !isBlank(server.getEmailVerificationToken()) + && StringUtils.hasText(server.getEmailVerificationToken()) && server.getCreatedAt() != null && server.getCreatedAt().before(cutoff) - && !isBlank(server.getDatabaseName()) + && StringUtils.hasText(server.getDatabaseName()) && !hasActivityEvidence(server); } private boolean hasActivityEvidence(Server server) { return server.getLastActivityAt() != null - || !isBlank(server.getApiKey()) + || StringUtils.hasText(server.getApiKey()) || positive(server.getUserCount()) || positive(server.getTicketCount()) || positive(server.getOnlinePlayerCount()); @@ -178,10 +179,6 @@ private boolean positive(Long value) { return value != null && value > 0; } - private boolean isBlank(String value) { - return value == null || value.isBlank(); - } - private static class CleanupStats { private int candidates; private int deleted; diff --git a/src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java b/src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java new file mode 100644 index 0000000..f3f4681 --- /dev/null +++ b/src/main/java/gg/modl/backend/replay/config/ReplayExecutorConfig.java @@ -0,0 +1,31 @@ +package gg.modl.backend.replay.config; + +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class ReplayExecutorConfig { + + public static final String TRAINING_SEGMENT_TASK_EXECUTOR = "trainingSegmentTaskExecutor"; + + @Bean(name = TRAINING_SEGMENT_TASK_EXECUTOR, autowireCandidate = false) + public ThreadPoolTaskExecutor trainingSegmentTaskExecutor( + @Value("${modl.replay.training-segment-executor.core-pool-size:1}") int corePoolSize, + @Value("${modl.replay.training-segment-executor.max-pool-size:2}") int maxPoolSize, + @Value("${modl.replay.training-segment-executor.queue-capacity:50}") int queueCapacity, + @Value("${modl.replay.training-segment-executor.await-termination-seconds:30}") int awaitTerminationSeconds + ) { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(corePoolSize); + executor.setMaxPoolSize(maxPoolSize); + executor.setQueueCapacity(queueCapacity); + executor.setThreadNamePrefix("training-segment-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(awaitTerminationSeconds); + return executor; + } +} diff --git a/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java b/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java index 5104ec9..06732a3 100644 --- a/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java +++ b/src/main/java/gg/modl/backend/replay/controller/MinecraftReplayController.java @@ -10,7 +10,6 @@ import jakarta.validation.Valid; import java.util.Map; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -18,7 +17,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -@Slf4j @RestController @RequestMapping(RESTMappingV1.MINECRAFT_REPLAYS) @RequiredArgsConstructor diff --git a/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java b/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java index 09b27a7..25698b7 100644 --- a/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java +++ b/src/main/java/gg/modl/backend/replay/controller/PanelReplayController.java @@ -1,5 +1,6 @@ package gg.modl.backend.replay.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.replay.service.ReplayService; @@ -18,6 +19,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_REPLAYS) +@RequiresPanelPermission("punishment.modify") @RequiredArgsConstructor public class PanelReplayController { private final ReplayService replayService; diff --git a/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java b/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java index 52ff2b5..762e6b6 100644 --- a/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java +++ b/src/main/java/gg/modl/backend/replay/controller/ReplayProtoMapper.java @@ -1,5 +1,7 @@ package gg.modl.backend.replay.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.nullToEmpty; + import gg.modl.backend.infrastructure.exception.ValidationException; import gg.modl.backend.replay.data.ReplayLabel; import gg.modl.proto.modl.v1.PublicReplayResponse; @@ -73,8 +75,4 @@ private ReplayLabel.CheatDetail toCheatDetail(SubmitReplayLabelsRequest.ReplayLa detail.setTimeRanges(ranges); return detail; } - - private static String nullToEmpty(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java b/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java index 1881dad..076aefc 100644 --- a/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java +++ b/src/main/java/gg/modl/backend/replay/data/TrainingSegmentDocument.java @@ -4,6 +4,7 @@ import java.util.Date; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; import org.bson.types.Binary; import org.springframework.data.annotation.Id; import org.springframework.data.mongodb.core.mapping.Document; @@ -20,13 +21,14 @@ public class TrainingSegmentDocument { private String serverDatabaseName; private String playerUuid; private String playerName; - private String verdict; // "legit" | "cheating" - private String cheatType; // "aim", "scaffold", etc. (null for legit) + private String verdict; + private String cheatType; private int confidence; private String notes; private long startMs; private long endMs; private String mcVersion; + @ToString.Exclude private Binary segmentBinary; private Date createdAt; } diff --git a/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java b/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java index db39301..ba3eb91 100644 --- a/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java +++ b/src/main/java/gg/modl/backend/replay/dto/PlayerReplayResponse.java @@ -35,9 +35,17 @@ public static PlayerReplayResponse fromTicket(Ticket ticket, String replayUrl, S public String deduplicationKey() { if (replayId != null && !replayId.isBlank()) { - return "id:" + replayId; + return idKey(replayId); } - return replayUrl != null ? "url:" + replayUrl : "empty"; + return replayUrl != null ? urlKey(replayUrl) : "empty"; + } + + public static String idKey(String replayId) { + return "id:" + replayId; + } + + public static String urlKey(String replayUrl) { + return "url:" + replayUrl; } private static String firstNonBlank(String first, String second) { diff --git a/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java b/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java index 5eb149e..0211cac 100644 --- a/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java +++ b/src/main/java/gg/modl/backend/replay/service/LegacyReplayCleanupService.java @@ -2,8 +2,9 @@ import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; import gg.modl.backend.database.mongo.repository.ReplayMongoRepository.ReplayCursor; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.infrastructure.scheduling.KeysetDrainer; import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; import gg.modl.backend.replay.config.LegacyReplayCleanupProperties; import gg.modl.backend.replay.data.ReplayDocument; @@ -31,7 +32,7 @@ public class LegacyReplayCleanupService { private static final String CLEANUP_LEASE = "legacy-replay-cleanup"; private final LegacyReplayCleanupProperties properties; - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; private final ReplayMongoRepository replayRepository; private final TicketMongoRepository ticketRepository; private final ReplayRetentionSettingsService replayRetentionSettingsService; @@ -58,7 +59,7 @@ public void runCleanupOnce() { } RunAggregate aggregate = new RunAggregate(); - List servers = serverRepository.findAll(); + List servers = serverLookupRepository.findAll(); for (Server server : servers) { try { ServerCleanupResult result = processServer(server); @@ -106,25 +107,16 @@ private ServerCleanupResult processServer(Server server) { } private long drain(Server server, Date cutoff, int pageSize, ReplayPageFinder finder, ServerCleanupResult result) { - long deletedTotal = 0; - ReplayCursor cursor = null; - int pages = 0; - while (pages++ < MAX_PAGES_PER_RUN) { - List page = finder.find(server, cutoff, cursor, pageSize); - if (page.isEmpty()) { - break; - } - result.scanned += page.size(); - List deletable = selectDeletable(server, page, result); - ReplayBatchDeletionResult deletion = replayDeletionService.deleteReplaysWithStorage(server, deletable); - deletedTotal += deletion.deleted(); - result.alreadyAbsent += deletion.alreadyAbsent(); - cursor = cursorFrom(page); - if (page.size() < pageSize) { - break; - } - } - return deletedTotal; + return KeysetDrainer.drain(MAX_PAGES_PER_RUN, pageSize, + (cursor, limit) -> finder.find(server, cutoff, cursor, limit), + this::cursorFrom, + page -> { + result.scanned += page.size(); + List deletable = selectDeletable(server, page, result); + ReplayBatchDeletionResult deletion = replayDeletionService.deleteReplaysWithStorage(server, deletable); + result.alreadyAbsent += deletion.alreadyAbsent(); + return deletion.deleted(); + }); } private List selectDeletable(Server server, List page, ServerCleanupResult result) { diff --git a/src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java b/src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java new file mode 100644 index 0000000..dac0b30 --- /dev/null +++ b/src/main/java/gg/modl/backend/replay/service/PlayerReplayListingService.java @@ -0,0 +1,123 @@ +package gg.modl.backend.replay.service; + +import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; +import gg.modl.backend.database.mongo.repository.TicketMongoRepository; +import gg.modl.backend.replay.data.ReplayDocument; +import gg.modl.backend.replay.dto.PlayerReplayResponse; +import gg.modl.backend.replay.util.ReplayReferenceUtil; +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.ticket.data.Ticket; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PlayerReplayListingService { + private final ReplayMongoRepository replayRepository; + private final S3StorageService s3StorageService; + private final StorageMetadataService storageMetadataService; + private final TicketMongoRepository ticketRepository; + + public List listPlayerReplays(Server server, String playerUuid) { + String normalizedPlayerUuid = ReplayReferenceUtil.requireValidUuid(playerUuid); + Map responses = new LinkedHashMap<>(); + + List directReplays = replayRepository.findByTargetUuid(server, normalizedPlayerUuid, 100); + Set orphanedReplayIds = orphanedCompleteReplayIds(server, directReplays); + for (ReplayDocument replay : directReplays) { + if (orphanedReplayIds.contains(replay.getId())) { + continue; + } + PlayerReplayResponse response = toPlayerReplayResponse(replay, PlayerReplayResponse.MatchSource.DIRECT_METADATA); + responses.put(response.deduplicationKey(), response); + } + + List tickets = ticketRepository.findPlayerTicketsWithReplayUrl(server, normalizedPlayerUuid, 100); + for (Ticket ticket : tickets) { + String replayUrl = ReplayReferenceUtil.normalize(ticket.getReplayUrl()); + if (replayUrl == null) { + continue; + } + String replayId = ReplayReferenceUtil.extractReplayId(replayUrl); + if (replayId != null && orphanedReplayIds.contains(replayId)) { + continue; + } + if (hasReplayReference(responses, replayUrl, replayId)) { + continue; + } + String key = replayId != null ? PlayerReplayResponse.idKey(replayId) : PlayerReplayResponse.urlKey(replayUrl); + if (responses.containsKey(key)) { + continue; + } + responses.put(key, PlayerReplayResponse.fromTicket(ticket, replayUrl, replayId)); + } + + return List.copyOf(responses.values()); + } + + private Set orphanedCompleteReplayIds(Server server, List replays) { + if (!storageMetadataService.isMetadataAuthoritative(server)) { + return Set.of(); + } + List completeStorageKeys = new ArrayList<>(); + for (ReplayDocument replay : replays) { + if (isCompleteWithStorageKey(replay)) { + completeStorageKeys.add(replay.getStorageKey()); + } + } + if (completeStorageKeys.isEmpty()) { + return Set.of(); + } + + Set existingStorageKeys = storageMetadataService.existingKeys(server, completeStorageKeys); + if (existingStorageKeys.size() == completeStorageKeys.size()) { + return Set.of(); + } + + Set orphanedReplayIds = new HashSet<>(); + for (ReplayDocument replay : replays) { + if (isCompleteWithStorageKey(replay) && !existingStorageKeys.contains(replay.getStorageKey())) { + orphanedReplayIds.add(replay.getId()); + } + } + return orphanedReplayIds; + } + + private boolean isCompleteWithStorageKey(ReplayDocument replay) { + return ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null; + } + + private PlayerReplayResponse toPlayerReplayResponse(ReplayDocument replay, PlayerReplayResponse.MatchSource matchSource) { + String replayUrl = ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null + ? s3StorageService.getCdnUrl(replay.getStorageKey()) + : null; + return new PlayerReplayResponse( + replay.getId(), + replay.getTargetUuid(), + replay.getTargetName(), + replay.getMcVersion(), + replay.getFileSize(), + replay.getCreatedAt(), + replay.getStatus(), + replayUrl, + matchSource + ); + } + + private boolean hasReplayReference(Map responses, String replayUrl, String replayId) { + return responses.values() + .stream() + .anyMatch(response -> + replayUrl.equals(response.replayUrl()) + || (replayId != null && replayId.equals(response.replayId())) + ); + } +} diff --git a/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java b/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java index ed41606..802c207 100644 --- a/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java +++ b/src/main/java/gg/modl/backend/replay/service/ReplayDeletionService.java @@ -6,6 +6,7 @@ import gg.modl.backend.replay.data.ReplayDocument; import gg.modl.backend.server.data.Server; import gg.modl.backend.storage.service.S3StorageService; +import gg.modl.backend.storage.service.StorageKeyUtils; import gg.modl.backend.storage.service.StorageMetadataService; import java.util.ArrayList; import java.util.Collection; @@ -96,7 +97,7 @@ private List replayStorageKeys(Collection keys) { } List replayKeys = new ArrayList<>(); for (String key : keys) { - if (key != null && REPLAY_STORAGE_CATEGORY.equals(S3StorageService.categorizeFile(key))) { + if (key != null && REPLAY_STORAGE_CATEGORY.equals(StorageKeyUtils.categorizeFile(key))) { replayKeys.add(key); } } diff --git a/src/main/java/gg/modl/backend/replay/service/ReplayService.java b/src/main/java/gg/modl/backend/replay/service/ReplayService.java index e52cb14..0e0de8b 100644 --- a/src/main/java/gg/modl/backend/replay/service/ReplayService.java +++ b/src/main/java/gg/modl/backend/replay/service/ReplayService.java @@ -1,64 +1,41 @@ package gg.modl.backend.replay.service; import gg.modl.backend.database.mongo.repository.ReplayMongoRepository; -import gg.modl.backend.database.mongo.repository.TicketMongoRepository; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; import gg.modl.backend.infrastructure.validation.RequestValidationLimits; import gg.modl.backend.replay.data.ReplayDocument; import gg.modl.backend.replay.data.ReplayLabel; import gg.modl.backend.replay.dto.InitReplayUploadResponse; -import gg.modl.backend.replay.dto.PlayerReplayResponse; import gg.modl.backend.replay.dto.PublicReplayResponse; import gg.modl.backend.replay.util.ReplayReferenceUtil; import gg.modl.backend.server.data.Server; import gg.modl.backend.storage.dto.response.PresignUploadResponse; 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.ticket.data.Ticket; import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import jakarta.validation.ConstraintViolation; -import jakarta.validation.Validator; -import java.util.ArrayList; 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @Service @Slf4j +@RequiredArgsConstructor public class ReplayService { private final ReplayMongoRepository replayRepository; private final S3StorageService s3StorageService; private final StorageQuotaService storageQuotaService; private final TrainingDataService trainingDataService; - private final StorageMetadataService storageMetadataService; - private final TicketMongoRepository ticketRepository; - private final Validator validator; + private final BeanValidationRunner validationRunner; @Value("${modl.replay.max-file-size:10485760}") private long maxFileSize; - public ReplayService(ReplayMongoRepository replayRepository, S3StorageService s3StorageService, - StorageQuotaService storageQuotaService, TrainingDataService trainingDataService, - StorageMetadataService storageMetadataService, TicketMongoRepository ticketRepository, - Validator validator) { - this.replayRepository = replayRepository; - this.s3StorageService = s3StorageService; - this.storageQuotaService = storageQuotaService; - this.trainingDataService = trainingDataService; - this.storageMetadataService = storageMetadataService; - this.ticketRepository = ticketRepository; - this.validator = validator; - } - public InitReplayUploadResponse initUpload( Server server, String mcVersion, @@ -70,7 +47,7 @@ public InitReplayUploadResponse initUpload( throw new ValidationException("File size exceeds maximum of " + (maxFileSize / 1024 / 1024) + " MB"); } - String normalizedTargetUuid = normalizeTargetUuid(targetUuid); + String normalizedTargetUuid = ReplayReferenceUtil.requireValidUuid(targetUuid); String normalizedTargetName = normalizeTargetName(targetName); if (!storageQuotaService.canUpload(server, fileSize)) { @@ -161,10 +138,7 @@ public SubmitLabelsResult submitLabels(Server server, String replayId, List labels) { for (ReplayLabel label : labels) { - Set> violations = validator.validate(label); - if (!violations.isEmpty()) { - throw new ValidationException(violations.iterator().next().getMessage()); - } + validationRunner.validate(label); } } @@ -182,113 +156,8 @@ public Optional getPublicReplay(Server server, String repl )); } - public List listPlayerReplays(Server server, String playerUuid) { - String normalizedPlayerUuid = normalizeTargetUuid(playerUuid); - Map responses = new LinkedHashMap<>(); - - List directReplays = replayRepository.findByTargetUuid(server, normalizedPlayerUuid, 100); - Set orphanedReplayIds = orphanedCompleteReplayIds(server, directReplays); - for (ReplayDocument replay : directReplays) { - if (orphanedReplayIds.contains(replay.getId())) { - continue; - } - PlayerReplayResponse response = toPlayerReplayResponse(replay, PlayerReplayResponse.MatchSource.DIRECT_METADATA); - responses.put(response.deduplicationKey(), response); - } - - List tickets = ticketRepository.findPlayerTicketsWithReplayUrl(server, normalizedPlayerUuid, 100); - for (Ticket ticket : tickets) { - String replayUrl = normalizeOptional(ticket.getReplayUrl()); - if (replayUrl == null) { - continue; - } - String replayId = ReplayReferenceUtil.extractReplayId(replayUrl); - if (replayId != null && orphanedReplayIds.contains(replayId)) { - continue; - } - if (hasReplayReference(responses, replayUrl, replayId)) { - continue; - } - String key = replayId != null ? replayIdKey(replayId) : replayUrlKey(replayUrl); - if (responses.containsKey(key)) { - continue; - } - responses.put(key, PlayerReplayResponse.fromTicket(ticket, replayUrl, replayId)); - } - - return List.copyOf(responses.values()); - } - - private Set orphanedCompleteReplayIds(Server server, List replays) { - if (!storageMetadataService.isMetadataAuthoritative(server)) { - return Set.of(); - } - List completeStorageKeys = new ArrayList<>(); - for (ReplayDocument replay : replays) { - if (isCompleteWithStorageKey(replay)) { - completeStorageKeys.add(replay.getStorageKey()); - } - } - if (completeStorageKeys.isEmpty()) { - return Set.of(); - } - - Set existingStorageKeys = storageMetadataService.existingKeys(server, completeStorageKeys); - if (existingStorageKeys.size() == completeStorageKeys.size()) { - return Set.of(); - } - - Set orphanedReplayIds = new HashSet<>(); - for (ReplayDocument replay : replays) { - if (isCompleteWithStorageKey(replay) && !existingStorageKeys.contains(replay.getStorageKey())) { - orphanedReplayIds.add(replay.getId()); - } - } - return orphanedReplayIds; - } - - private boolean isCompleteWithStorageKey(ReplayDocument replay) { - return ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null; - } - - private PlayerReplayResponse toPlayerReplayResponse(ReplayDocument replay, PlayerReplayResponse.MatchSource matchSource) { - String replayUrl = ReplayDocument.STATUS_COMPLETE.equals(replay.getStatus()) && replay.getStorageKey() != null - ? s3StorageService.getCdnUrl(replay.getStorageKey()) - : null; - return new PlayerReplayResponse( - replay.getId(), - replay.getTargetUuid(), - replay.getTargetName(), - replay.getMcVersion(), - replay.getFileSize(), - replay.getCreatedAt(), - replay.getStatus(), - replayUrl, - matchSource - ); - } - - private String normalizeOptional(String value) { - if (value == null || value.isBlank()) { - return null; - } - return value.trim(); - } - - private String normalizeTargetUuid(String value) { - String normalized = normalizeOptional(value); - if (normalized == null) { - return null; - } - try { - return UUID.fromString(normalized).toString(); - } catch (IllegalArgumentException exception) { - throw new ValidationException("Target UUID must be a valid UUID"); - } - } - private String normalizeTargetName(String value) { - String normalized = normalizeOptional(value); + String normalized = ReplayReferenceUtil.normalize(value); if (normalized == null) { return null; } @@ -297,21 +166,4 @@ private String normalizeTargetName(String value) { } return normalized; } - - private String replayUrlKey(String replayUrl) { - return "url:" + replayUrl; - } - - private String replayIdKey(String replayId) { - return "id:" + replayId; - } - - private boolean hasReplayReference(Map responses, String replayUrl, String replayId) { - return responses.values() - .stream() - .anyMatch(response -> - replayUrl.equals(response.replayUrl()) - || (replayId != null && replayId.equals(response.replayId())) - ); - } } diff --git a/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java b/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java index b44bce3..266a49e 100644 --- a/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java +++ b/src/main/java/gg/modl/backend/replay/service/TrainingDataService.java @@ -1,6 +1,7 @@ package gg.modl.backend.replay.service; import gg.modl.backend.database.mongo.repository.TrainingSegmentRepository; +import gg.modl.backend.replay.config.ReplayExecutorConfig; import gg.modl.backend.replay.data.ReplayDocument; import gg.modl.backend.replay.data.ReplayLabel; import gg.modl.backend.replay.data.TrainingSegmentDocument; @@ -47,7 +48,7 @@ public class TrainingDataService { private static final String VERDICT_CHEATING = "cheating"; private static final String VERDICT_LEGIT = "legit"; - @Async + @Async(ReplayExecutorConfig.TRAINING_SEGMENT_TASK_EXECUTOR) public void generateSegmentsAsync(Server server, ReplayDocument doc, List labels) { try { generateSegments(server, doc, labels); diff --git a/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java b/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java index 86285a3..8f48b76 100644 --- a/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java +++ b/src/main/java/gg/modl/backend/replay/util/ReplayReferenceUtil.java @@ -1,9 +1,11 @@ package gg.modl.backend.replay.util; +import gg.modl.backend.infrastructure.exception.ValidationException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.UUID; public final class ReplayReferenceUtil { private static final String REPLAY_ID_QUERY_PARAM = "id"; @@ -11,6 +13,18 @@ public final class ReplayReferenceUtil { private ReplayReferenceUtil() { } + public static String requireValidUuid(String value) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + try { + return UUID.fromString(normalized).toString(); + } catch (IllegalArgumentException exception) { + throw new ValidationException("Target UUID must be a valid UUID"); + } + } + public static String extractReplayId(String replayReference) { String normalized = normalize(replayReference); if (normalized == null) { @@ -40,7 +54,7 @@ public static String extractReplayId(String replayReference) { } } - private static String normalize(String value) { + public static String normalize(String value) { if (value == null || value.isBlank()) { return null; } diff --git a/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java b/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java index cc6e8dd..54dcb0f 100644 --- a/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java +++ b/src/main/java/gg/modl/backend/replaylite/data/ReplayLiteLabelRange.java @@ -9,6 +9,6 @@ public record ReplayLiteLabelRange( ) { @AssertTrue public boolean isEndAfterStart() { - return endMs > startMs; + return endMs >= startMs; } } diff --git a/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java b/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java index c8055a8..e882d00 100644 --- a/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java +++ b/src/main/java/gg/modl/backend/replaylite/repository/ReplayLiteMongoRepository.java @@ -73,10 +73,6 @@ private Criteria keysetAfter(String sortField, ReplayLiteCursor after) { ); } - public ReplayLiteDocument saveEntity(ReplayLiteDocument document) { - return super.saveEntity(document); - } - public boolean claimLabels(String replayId, Instant now, List labels, String labelIp) { Query query = Query.query(Criteria.where(ReplayLiteDocumentFields.ID).is(replayId) .and(ReplayLiteDocumentFields.STATUS).is(ReplayLiteStatus.CONFIRMED) diff --git a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java index 7d6e2b5..3db29e5 100644 --- a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java +++ b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteAbuseGuard.java @@ -1,17 +1,16 @@ package gg.modl.backend.replaylite.service; import gg.modl.backend.infrastructure.exception.ValidationException; -import java.time.Clock; +import gg.modl.backend.infrastructure.ratelimit.BucketPool; +import io.github.bucket4j.Bucket; import java.time.Duration; -import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.UUID; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service +@RequiredArgsConstructor public class ReplayLiteAbuseGuard { - private static final int MAX_TRACKED_KEYS = 50_000; private static final int MAX_INIT_ATTEMPTS_PER_HOUR = 40; private static final int MAX_CONFIRMS_PER_HOUR = 80; private static final int MAX_LABELS_PER_HOUR = 80; @@ -19,40 +18,40 @@ public class ReplayLiteAbuseGuard { private static final int MAX_REQUESTS_PER_IP_PER_MINUTE = 120; private static final int MAX_ACTIVE_PENDING_PER_SERVER = 25; - private final Clock clock; - private final Map counters = new LinkedHashMap<>(64, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > MAX_TRACKED_KEYS; - } - }; + private static final Duration HOURLY_WINDOW = Duration.ofHours(1); + private static final Duration PER_MINUTE_WINDOW = Duration.ofMinutes(1); - public ReplayLiteAbuseGuard() { - this(Clock.systemUTC()); - } + private static final String INIT_NAMESPACE = "replaylite:init"; + private static final String CONFIRM_NAMESPACE = "replaylite:confirm"; + private static final String LABEL_NAMESPACE = "replaylite:label"; + private static final String DOWNLOAD_NAMESPACE = "replaylite:download"; + private static final String IP_NAMESPACE = "replaylite:ip"; - ReplayLiteAbuseGuard(Clock clock) { - this.clock = clock; - } + private final BucketPool bucketPool; public void checkInit(UUID pluginServerUuid) { - consume("init:" + pluginServerUuid, MAX_INIT_ATTEMPTS_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite upload attempts"); + consume(INIT_NAMESPACE, pluginServerUuid.toString(), MAX_INIT_ATTEMPTS_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite upload attempts"); } public void checkConfirm(UUID pluginServerUuid) { - consume("confirm:" + pluginServerUuid, MAX_CONFIRMS_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite confirm attempts"); + consume(CONFIRM_NAMESPACE, pluginServerUuid.toString(), MAX_CONFIRMS_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite confirm attempts"); } public void checkLabel(String replayId) { - consume("label:" + replayId, MAX_LABELS_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite label submissions"); + consume(LABEL_NAMESPACE, replayId, MAX_LABELS_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite label submissions"); } public void checkDownload(String replayId) { - consume("download:" + replayId, MAX_DOWNLOADS_PER_REPLAY_PER_HOUR, Duration.ofHours(1), "Too many Replay Lite download requests"); + consume(DOWNLOAD_NAMESPACE, replayId, MAX_DOWNLOADS_PER_REPLAY_PER_HOUR, HOURLY_WINDOW, + "Too many Replay Lite download requests"); } public void checkIp(String clientIp) { - consume("ip:" + clientIp, MAX_REQUESTS_PER_IP_PER_MINUTE, Duration.ofMinutes(1), "Too many Replay Lite requests"); + consume(IP_NAMESPACE, clientIp, MAX_REQUESTS_PER_IP_PER_MINUTE, PER_MINUTE_WINDOW, + "Too many Replay Lite requests"); } public void checkPendingUploads(long activePendingCount) { @@ -61,20 +60,10 @@ public void checkPendingUploads(long activePendingCount) { } } - private synchronized void consume(String key, int limit, Duration window, String message) { - Instant now = clock.instant(); - WindowCounter counter = counters.get(key); - if (counter == null || !now.isBefore(counter.expiresAt())) { - counters.put(key, new WindowCounter(1, now.plus(window))); - return; - } - - if (counter.count() >= limit) { + private void consume(String namespace, String key, int limit, Duration window, String message) { + Bucket bucket = bucketPool.resolveBucket(namespace, key, limit, window); + if (!bucket.tryConsume(1)) { throw new ValidationException(message); } - - counters.put(key, new WindowCounter(counter.count() + 1, counter.expiresAt())); } - - private record WindowCounter(int count, Instant expiresAt) {} } diff --git a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java index a469d59..b8b149c 100644 --- a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java +++ b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteCleanupService.java @@ -1,5 +1,6 @@ package gg.modl.backend.replaylite.service; +import gg.modl.backend.infrastructure.scheduling.KeysetDrainer; import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; import gg.modl.backend.replaylite.data.ReplayLiteDocument; import gg.modl.backend.replaylite.repository.ReplayLiteMongoRepository; @@ -48,20 +49,16 @@ public void cleanupExpiredReplays() { } private void drain(ReplayLitePageFinder finder, Function sortValue) { - ReplayLiteCursor cursor = null; - int batches = 0; - while (batches++ < MAX_BATCHES_PER_RUN) { - List page = finder.find(cursor, CLEANUP_BATCH_SIZE); - if (page.isEmpty()) { - break; - } - cleanup(page); - ReplayLiteDocument last = page.get(page.size() - 1); - cursor = new ReplayLiteCursor(sortValue.apply(last), last.getId()); - if (page.size() < CLEANUP_BATCH_SIZE) { - break; - } - } + KeysetDrainer.drain(MAX_BATCHES_PER_RUN, CLEANUP_BATCH_SIZE, + finder::find, + page -> { + ReplayLiteDocument last = page.get(page.size() - 1); + return new ReplayLiteCursor(sortValue.apply(last), last.getId()); + }, + page -> { + cleanup(page); + return 0L; + }); } private void cleanup(Iterable documents) { diff --git a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java index 3781122..a3b40c2 100644 --- a/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java +++ b/src/main/java/gg/modl/backend/replaylite/service/ReplayLiteService.java @@ -26,7 +26,6 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service @@ -37,6 +36,8 @@ public class ReplayLiteService { private static final Duration DOWNLOAD_PRESIGN_TTL = Duration.ofMinutes(5); private static final int DAILY_CONFIRMED_LIMIT = 100; private static final DateTimeFormatter KEY_DATE_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + private static final String REPLAY_NOT_FOUND = "Replay not found"; + private static final String UPLOAD_NOT_PENDING = "Replay Lite upload is not pending"; private final ReplayLiteMongoRepository repository; private final ReplayLiteQuotaMongoRepository quotaRepository; @@ -44,16 +45,6 @@ public class ReplayLiteService { private final ReplayLiteAbuseGuard abuseGuard; private final Clock clock; - @Autowired - public ReplayLiteService( - ReplayLiteMongoRepository repository, - ReplayLiteQuotaMongoRepository quotaRepository, - ReplayLiteStorageService storageService, - ReplayLiteAbuseGuard abuseGuard - ) { - this(repository, quotaRepository, storageService, abuseGuard, Clock.systemUTC()); - } - public ReplayLiteService( ReplayLiteMongoRepository repository, ReplayLiteQuotaMongoRepository quotaRepository, @@ -114,10 +105,10 @@ public void confirmUpload(Server server, String replayId, String clientIp) { UUID pluginServerUuid = authenticatedServerUuid(server); ReplayLiteDocument document = repository.findByReplayId(replayId) - .orElseThrow(() -> new ResourceNotFoundException("Replay not found")); + .orElseThrow(() -> new ResourceNotFoundException(REPLAY_NOT_FOUND)); if (!pluginServerUuid.equals(document.getPluginServerUuid())) { - throw new ResourceNotFoundException("Replay not found"); + throw new ResourceNotFoundException(REPLAY_NOT_FOUND); } abuseGuard.checkConfirm(pluginServerUuid); @@ -138,33 +129,30 @@ public void confirmUpload(Server server, String replayId, String clientIp) { LocalDate quotaDay = LocalDate.ofInstant(clock.instant(), ZoneOffset.UTC); QuotaReservationResult quotaReservation = reserveDailyQuota(document.getPluginServerUuid(), document.getId(), quotaDay); if (quotaReservation == QuotaReservationResult.ALREADY_RESERVED) { - throw new ConflictException("Replay Lite upload is not pending"); + throw new ConflictException(UPLOAD_NOT_PENDING); } boolean confirmed; try { confirmed = confirmPendingUpload(document, actualSize, clientIp); } catch (RuntimeException e) { - if (quotaReservation == QuotaReservationResult.RESERVED && shouldReleaseFailedConfirmation(document.getId())) { - quotaRepository.releaseConfirmedUpload( - document.getPluginServerUuid(), - quotaDay, - document.getId(), - clock.instant() - ); - } + releaseQuotaOnFailedConfirmation(document, quotaReservation, quotaDay); throw e; } if (!confirmed) { - if (quotaReservation == QuotaReservationResult.RESERVED && shouldReleaseFailedConfirmation(document.getId())) { - quotaRepository.releaseConfirmedUpload( - document.getPluginServerUuid(), - quotaDay, - document.getId(), - clock.instant() - ); - } - throw new ConflictException("Replay Lite upload is not pending"); + releaseQuotaOnFailedConfirmation(document, quotaReservation, quotaDay); + throw new ConflictException(UPLOAD_NOT_PENDING); + } + } + + private void releaseQuotaOnFailedConfirmation(ReplayLiteDocument document, QuotaReservationResult quotaReservation, LocalDate quotaDay) { + if (quotaReservation == QuotaReservationResult.RESERVED && shouldReleaseFailedConfirmation(document.getId())) { + quotaRepository.releaseConfirmedUpload( + document.getPluginServerUuid(), + quotaDay, + document.getId(), + clock.instant() + ); } } @@ -200,11 +188,11 @@ public void submitLabels(String replayId, List labels, String c abuseGuard.checkLabel(replayId); ReplayLiteDocument document = repository.findByReplayId(replayId) - .orElseThrow(() -> new ResourceNotFoundException("Replay not found")); + .orElseThrow(() -> new ResourceNotFoundException(REPLAY_NOT_FOUND)); Instant now = clock.instant(); - if (document.getStatus() != ReplayLiteStatus.CONFIRMED || document.getExpiresAt() == null || !document.getExpiresAt().isAfter(now)) { - throw new ResourceNotFoundException("Replay not found"); + if (!isConfirmedAndUnexpired(document, now)) { + throw new ResourceNotFoundException(REPLAY_NOT_FOUND); } if (document.getLabels() != null && !document.getLabels().isEmpty()) { throw new ConflictException("This replay has already been labeled"); @@ -212,11 +200,8 @@ public void submitLabels(String replayId, List labels, String c if (!repository.claimLabels(document.getId(), now, normalizeLabels(labels), clientIp)) { Optional current = repository.findByReplayId(replayId); - if (current.isEmpty() - || current.get().getStatus() != ReplayLiteStatus.CONFIRMED - || current.get().getExpiresAt() == null - || !current.get().getExpiresAt().isAfter(now)) { - throw new ResourceNotFoundException("Replay not found"); + if (current.isEmpty() || !isConfirmedAndUnexpired(current.get(), now)) { + throw new ResourceNotFoundException(REPLAY_NOT_FOUND); } throw new ConflictException("This replay has already been labeled"); } @@ -235,8 +220,13 @@ public void releaseUnconfirmedDailyQuota(ReplayLiteDocument document) { private Optional findAvailablePublicReplay(String replayId) { Instant now = clock.instant(); return repository.findByReplayId(replayId) - .filter(document -> document.getStatus() == ReplayLiteStatus.CONFIRMED) - .filter(document -> document.getExpiresAt() != null && document.getExpiresAt().isAfter(now)); + .filter(document -> isConfirmedAndUnexpired(document, now)); + } + + private boolean isConfirmedAndUnexpired(ReplayLiteDocument document, Instant now) { + return document.getStatus() == ReplayLiteStatus.CONFIRMED + && document.getExpiresAt() != null + && document.getExpiresAt().isAfter(now); } private List normalizeLabels(List labels) { @@ -271,7 +261,7 @@ private UUID authenticatedServerUuid(Server server) { private void requirePending(ReplayLiteDocument document) { if (document.getStatus() != ReplayLiteStatus.PENDING) { - throw new ValidationException("Replay Lite upload is not pending"); + throw new ValidationException(UPLOAD_NOT_PENDING); } } diff --git a/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java b/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java index 1278813..9be0b4a 100644 --- a/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java +++ b/src/main/java/gg/modl/backend/replaylite/storage/ReplayLiteStorageService.java @@ -1,7 +1,7 @@ package gg.modl.backend.replaylite.storage; import gg.modl.backend.infrastructure.exception.ExternalServiceException; -import java.net.URI; +import gg.modl.backend.storage.config.S3ClientFactory; import java.time.Duration; import java.time.Instant; import java.util.HashMap; @@ -12,9 +12,6 @@ import org.springframework.http.ContentDisposition; import org.springframework.lang.Nullable; import org.springframework.stereotype.Service; -import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; -import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; -import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; import software.amazon.awssdk.services.s3.model.GetObjectRequest; @@ -60,17 +57,11 @@ private static S3Client createClient(ReplayLiteStorageConfiguration configuratio return null; } - AwsBasicCredentials credentials = AwsBasicCredentials.create( + return S3ClientFactory.createClient( + configuration.getEndpoint(), configuration.getKeyId(), configuration.getApplicationKey() ); - - return S3Client.builder() - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .endpointOverride(URI.create(configuration.getEndpoint())) - .region(Region.US_EAST_1) - .forcePathStyle(true) - .build(); } private static S3Presigner createPresigner(ReplayLiteStorageConfiguration configuration) { @@ -78,16 +69,11 @@ private static S3Presigner createPresigner(ReplayLiteStorageConfiguration config return null; } - AwsBasicCredentials credentials = AwsBasicCredentials.create( + return S3ClientFactory.createPresigner( + configuration.getEndpoint(), configuration.getKeyId(), configuration.getApplicationKey() ); - - return S3Presigner.builder() - .credentialsProvider(StaticCredentialsProvider.create(credentials)) - .endpointOverride(URI.create(configuration.getEndpoint())) - .region(Region.US_EAST_1) - .build(); } public boolean isConfigured() { @@ -144,14 +130,6 @@ public Optional headObject(String objectKey) { } } - public String getPublicUrl(String objectKey) { - String cdnDomain = configuration.getCdnDomain(); - if (cdnDomain == null || cdnDomain.isBlank()) { - throw new ExternalServiceException("Replay Lite CDN domain is not configured"); - } - return "https://" + cdnDomain + "/" + objectKey; - } - public PresignedDownload createPresignedDownload(String objectKey, String downloadFilename, Duration duration) { if (!isConfigured()) { throw new ExternalServiceException("Replay Lite storage is not configured"); diff --git a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java index eeedb55..f28bbc2 100644 --- a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java +++ b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesProtoMapper.java @@ -1,13 +1,14 @@ package gg.modl.backend.role.controller; -import com.google.protobuf.Timestamp; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalStrings; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalTimestamp; + import gg.modl.backend.role.dto.response.RoleResponse; import gg.modl.proto.modl.v1.MinecraftRole; import gg.modl.proto.modl.v1.MinecraftRoleDetailResponse; import gg.modl.proto.modl.v1.MinecraftRoleListResponse; -import java.util.Date; import java.util.List; -import java.util.function.Consumer; final class MinecraftRolesProtoMapper { private MinecraftRolesProtoMapper() { @@ -38,40 +39,13 @@ private static MinecraftRole toMinecraftRole(RoleResponse role) { .setIsDefault(role.isDefault()) .setOrder(role.order()); - setIfNotNull(builder::setId, role.id()); - setIfNotNull(builder::setName, role.name()); - setIfNotNull(builder::setDescription, role.description()); - addAllIfNotNull(builder::addAllPermissions, role.permissions()); - setTimestampIfNotNull(builder::setCreatedAt, role.createdAt()); - setTimestampIfNotNull(builder::setUpdatedAt, role.updatedAt()); + setOptionalString(builder::setId, role.id()); + setOptionalString(builder::setName, role.name()); + setOptionalString(builder::setDescription, role.description()); + setOptionalStrings(builder::addAllPermissions, role.permissions()); + setOptionalTimestamp(builder::setCreatedAt, role.createdAt()); + setOptionalTimestamp(builder::setUpdatedAt, role.updatedAt()); return builder.build(); } - - private static void setIfNotNull(Consumer setter, String value) { - if (value != null) { - setter.accept(value); - } - } - - private static void addAllIfNotNull( - Consumer> setter, - List values - ) { - if (values != null) { - setter.accept(values); - } - } - - private static void setTimestampIfNotNull( - Consumer setter, - Date date - ) { - if (date != null) { - setter.accept(Timestamp.newBuilder() - .setSeconds(date.getTime() / 1_000L) - .setNanos((int) (date.getTime() % 1_000L) * 1_000_000) - .build()); - } - } } diff --git a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java index 48b560c..74cf2d1 100644 --- a/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java +++ b/src/main/java/gg/modl/backend/role/controller/MinecraftRolesV3Controller.java @@ -8,7 +8,6 @@ import gg.modl.backend.role.service.RoleService; import gg.modl.backend.server.data.Server; import gg.modl.proto.modl.v1.ApiError; -import gg.modl.proto.modl.v1.MinecraftRoleDetailResponse; import gg.modl.proto.modl.v1.MinecraftRoleListResponse; import gg.modl.proto.modl.v1.MinecraftRoleMutationResponse; import gg.modl.proto.modl.v1.UpdateRolePermissionsRequest; 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 e274aa7..c376191 100644 --- a/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java +++ b/src/main/java/gg/modl/backend/role/controller/PanelRoleController.java @@ -1,6 +1,7 @@ package gg.modl.backend.role.controller; -import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; import gg.modl.backend.infrastructure.rest.RESTMappingV1; import gg.modl.backend.infrastructure.rest.RequestUtil; import gg.modl.backend.log.service.PanelActionAuditor; @@ -19,11 +20,8 @@ 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; @@ -38,6 +36,7 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_ROLES) +@RequiresPanelPermission("admin.staff.manage.roles") @RequiredArgsConstructor public class PanelRoleController { private final RoleService roleService; @@ -45,7 +44,7 @@ public class PanelRoleController { private final RoleAuthorization roleAuthorization; private final RealtimeEventPublisher realtimeEventPublisher; private final PanelActionAuditor panelActionAuditor; - private final Validator validator; + private final BeanValidationRunner validationRunner; @GetMapping public ResponseEntity getAllRoles(HttpServletRequest request) { @@ -85,7 +84,7 @@ public ResponseEntity createRole( RoleAuthorization.PerformerAuthority performer = roleAuthorization.panelPerformer(server, performerEmail); RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(createRequest); - validate(mappedRequest); + validationRunner.validate(mappedRequest); RoleResponse role = roleService.createRole(server, mappedRequest, performer); invalidateRoles(server, role.id()); panelActionAuditor.recordStaffAction(server, performerEmail, "Created staff role: " + role.name()); @@ -104,7 +103,7 @@ public ResponseEntity updateRole( roleAuthorization.panelPerformer(server, RequestUtil.getSessionEmail(request)); RoleRequest mappedRequest = PanelRoleProtoMapper.toRoleRequest(updateRequest); - validate(mappedRequest); + validationRunner.validate(mappedRequest); return roleService.updateRole(server, id, mappedRequest, performer) .map(role -> { invalidateRoles(server, role.id()); @@ -152,10 +151,4 @@ 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/controller/PanelRoleProtoMapper.java b/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java index bdb3202..6880509 100644 --- a/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java +++ b/src/main/java/gg/modl/backend/role/controller/PanelRoleProtoMapper.java @@ -1,5 +1,8 @@ package gg.modl.backend.role.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalEpochMillis; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalString; + import gg.modl.backend.role.data.Permission; import gg.modl.backend.role.dto.request.ReorderRolesRequest; import gg.modl.backend.role.dto.request.RoleRequest; @@ -8,10 +11,8 @@ import gg.modl.proto.modl.v1.PermissionsResponse; import gg.modl.proto.modl.v1.RoleDetailResponse; import gg.modl.proto.modl.v1.RoleMutationResponse; -import java.util.Date; import java.util.List; import java.util.Map; -import java.util.function.Consumer; final class PanelRoleProtoMapper { private PanelRoleProtoMapper() { @@ -63,10 +64,10 @@ static PermissionsResponse toPermissionsResponse(List permissions, M private static PermissionsResponse.Permission toPermission(Permission permission) { PermissionsResponse.Permission.Builder builder = PermissionsResponse.Permission.newBuilder(); - setIfNotNull(builder::setId, permission.id()); - setIfNotNull(builder::setName, permission.name()); - setIfNotNull(builder::setDescription, permission.description()); - setIfNotNull(builder::setCategory, permission.category()); + setOptionalString(builder::setId, permission.id()); + setOptionalString(builder::setName, permission.name()); + setOptionalString(builder::setDescription, permission.description()); + setOptionalString(builder::setCategory, permission.category()); if (permission.parentId() != null) { builder.setParentId(permission.parentId()); } @@ -78,26 +79,14 @@ private static gg.modl.proto.modl.v1.RoleResponse toRoleResponse(RoleResponse ro .setIsDefault(role.isDefault()) .setOrder(role.order()) .setUserCount(role.userCount()); - setIfNotNull(builder::setId, role.id()); - setIfNotNull(builder::setName, role.name()); - setIfNotNull(builder::setDescription, role.description()); + setOptionalString(builder::setId, role.id()); + setOptionalString(builder::setName, role.name()); + setOptionalString(builder::setDescription, role.description()); if (role.permissions() != null) { builder.addAllPermissions(role.permissions()); } - setEpochMillisIfNotNull(builder::setCreatedAt, role.createdAt()); - setEpochMillisIfNotNull(builder::setUpdatedAt, role.updatedAt()); + setOptionalEpochMillis(builder::setCreatedAt, role.createdAt()); + setOptionalEpochMillis(builder::setUpdatedAt, role.updatedAt()); return builder.build(); } - - private static void setIfNotNull(Consumer setter, String value) { - if (value != null) { - setter.accept(value); - } - } - - private static void setEpochMillisIfNotNull(Consumer setter, Date date) { - if (date != null) { - setter.accept(date.getTime()); - } - } } 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 b4e40cb..588a3f3 100644 --- a/src/main/java/gg/modl/backend/role/service/PermissionService.java +++ b/src/main/java/gg/modl/backend/role/service/PermissionService.java @@ -35,15 +35,33 @@ public class PermissionService { .expireAfterWrite(Duration.ofMinutes(2)) .build(); + public static final String ADMIN_SETTINGS_VIEW = "admin.settings.view"; + public static final String ADMIN_STAFF_MANAGE = "admin.staff.manage"; + public static final String ADMIN_AUDIT_VIEW = "admin.audit.view"; + public static final String ADMIN_AUDIT_ROLLBACK = "admin.audit.rollback"; + public static final String PUNISHMENT_VIEW = "punishment.view"; + public static final String PUNISHMENT_MODIFY = "punishment.modify"; + public static final String TICKET_VIEW_ALL = "ticket.view.all"; + public static final String TICKET_REPLY_ALL = "ticket.reply.all"; + public static final String APPEAL_MODIFY = "appeal.modify"; + public static final String TICKET_CLOSE_ALL = "ticket.close.all"; + public static final String STAFF_CHAT_TOGGLE = "staff.chat.toggle"; + public static final String STAFF_CHAT_CLEAR = "staff.chat.clear"; + public static final String STAFF_CHAT_SLOW = "staff.chat.slow"; + public static final String STAFF_MAINTENANCE = "staff.maintenance"; + public static final String STAFF_MODACTIONS = "staff.modactions"; + public static final String STAFF_INTERCEPT = "staff.intercept"; + public static final String STAFF_CHATLOGS = "staff.chatlogs"; + public static final String STAFF_COMMANDLOGS = "staff.commandlogs"; + 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_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"), @@ -52,41 +70,36 @@ public class PermissionService { 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(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), + new Permission(ADMIN_AUDIT_ROLLBACK, "Rollback Audit Actions", "Roll back punishments and perform destructive bulk audit operations", "admin"), + 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), + 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"), + 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"), diff --git a/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java b/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java index 8f760f9..adbb792 100644 --- a/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java +++ b/src/main/java/gg/modl/backend/role/service/RoleAuthorization.java @@ -18,7 +18,7 @@ public class RoleAuthorization { public static final String MANAGE_MEMBERS_PERMISSION = "admin.staff.manage.members"; public static final String MANAGE_ROLES_PERMISSION = "admin.staff.manage.roles"; - private static final String SUPER_ADMIN_ROLE_NAME = "Super Admin"; + public static final String SUPER_ADMIN_ROLE_NAME = "Super Admin"; private static final String NO_AUTHORITY_MESSAGE = "You do not have authority to perform this action"; private static final String NO_GRANT_AUTHORITY_MESSAGE = "You do not have authority to grant this role"; private static final String HIGHER_AUTHORITY_MESSAGE = "You do not have authority over a role at or above your own level"; 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 168f3c4..24baa5c 100644 --- a/src/main/java/gg/modl/backend/role/service/RoleService.java +++ b/src/main/java/gg/modl/backend/role/service/RoleService.java @@ -165,12 +165,8 @@ public RoleResponse createRole(Server server, RoleRequest request, RoleAuthoriza 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; + String id = generateCustomRoleId(); + int nextOrder = nextRoleOrder(server); StaffRole newRole = StaffRole.builder() .id(id) @@ -188,6 +184,15 @@ public RoleResponse createRole(Server server, RoleRequest request, RoleAuthoriza return toRoleResponse(newRole, 0); } + private static String generateCustomRoleId() { + return "custom-" + System.currentTimeMillis() + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + private int nextRoleOrder(Server server) { + StaffRole highestRole = staffRoleRepository.findHighestOrdered(server).orElse(null); + return highestRole != null ? highestRole.getOrder() + 1 : 4; + } + private List filterToGrantablePermissions(StaffRole performerRole, List permissions) { return permissions.stream() .filter(p -> RoleAuthorization.roleGrants(performerRole, p)) @@ -326,20 +331,21 @@ public void createDefaultRoles(Server server) { 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" + PermissionService.ADMIN_SETTINGS_VIEW, PermissionService.ADMIN_STAFF_MANAGE, PermissionService.ADMIN_AUDIT_VIEW, + PermissionService.ADMIN_AUDIT_ROLLBACK, + PermissionService.PUNISHMENT_VIEW, PermissionService.PUNISHMENT_MODIFY, + PermissionService.TICKET_VIEW_ALL, PermissionService.TICKET_REPLY_ALL, PermissionService.APPEAL_MODIFY, PermissionService.TICKET_CLOSE_ALL, + PermissionService.STAFF_CHAT_TOGGLE, PermissionService.STAFF_CHAT_CLEAR, PermissionService.STAFF_CHAT_SLOW, + PermissionService.STAFF_MAINTENANCE, PermissionService.STAFF_MODACTIONS, + PermissionService.STAFF_INTERCEPT, PermissionService.STAFF_CHATLOGS, PermissionService.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" + PermissionService.PUNISHMENT_VIEW, PermissionService.PUNISHMENT_MODIFY, + PermissionService.TICKET_VIEW_ALL, PermissionService.TICKET_REPLY_ALL, PermissionService.APPEAL_MODIFY, PermissionService.TICKET_CLOSE_ALL, + PermissionService.STAFF_MODACTIONS, + PermissionService.STAFF_CHATLOGS, PermissionService.STAFF_COMMANDLOGS )); moderatorPerms.addAll(moderatorPunishmentPerms); @@ -378,7 +384,7 @@ public void createDefaultRoles(Server server) { .id("helper") .name("Helper") .description("Basic support permissions") - .permissions(new ArrayList<>(List.of("ticket.view.all", "ticket.reply.all", "appeal.modify"))) + .permissions(new ArrayList<>(List.of(PermissionService.TICKET_VIEW_ALL, PermissionService.TICKET_REPLY_ALL, PermissionService.APPEAL_MODIFY))) .isDefault(true) .order(3) .createdAt(new Date()) diff --git a/src/main/java/gg/modl/backend/server/ServerField.java b/src/main/java/gg/modl/backend/server/ServerField.java index 99dbe10..73e011b 100644 --- a/src/main/java/gg/modl/backend/server/ServerField.java +++ b/src/main/java/gg/modl/backend/server/ServerField.java @@ -7,5 +7,4 @@ public final class ServerField { public static final String CUSTOM_DOMAIN = "customDomainOverride"; public static final String CUSTOM_DOMAIN_STATUS = "customDomainStatus"; public static final String CUSTOM_DOMAIN_GRANDFATHERED = "customDomainGrandfathered"; - public static final String API_KEY = "apiKey"; } diff --git a/src/main/java/gg/modl/backend/server/ServerResponseMessage.java b/src/main/java/gg/modl/backend/server/ServerResponseMessage.java deleted file mode 100644 index ef89405..0000000 --- a/src/main/java/gg/modl/backend/server/ServerResponseMessage.java +++ /dev/null @@ -1,10 +0,0 @@ -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 80722e4..7120501 100644 --- a/src/main/java/gg/modl/backend/server/ServerService.java +++ b/src/main/java/gg/modl/backend/server/ServerService.java @@ -4,19 +4,18 @@ 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.infrastructure.util.DigestUtils; +import gg.modl.backend.database.mongo.repository.ServerCredentialRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; +import gg.modl.backend.database.mongo.repository.ServerProvisioningRepository; 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; @@ -27,7 +26,9 @@ @Service public class ServerService { - private final ServerMongoRepository serverRepository; + private final ServerLookupRepository serverLookupRepository; + private final ServerProvisioningRepository serverProvisioningRepository; + private final ServerCredentialRepository serverCredentialRepository; private final ServerProvisioningService provisioningService; private final Set appDomains; public static final String SERVER_DATABASE_PREFIX = "server_"; @@ -43,11 +44,15 @@ public class ServerService { .build(); public ServerService( - ServerMongoRepository serverRepository, + ServerLookupRepository serverLookupRepository, + ServerProvisioningRepository serverProvisioningRepository, + ServerCredentialRepository serverCredentialRepository, ServerProvisioningService provisioningService, ModlCorsProperties corsProperties ) { - this.serverRepository = serverRepository; + this.serverLookupRepository = serverLookupRepository; + this.serverProvisioningRepository = serverProvisioningRepository; + this.serverCredentialRepository = serverCredentialRepository; this.provisioningService = provisioningService; this.appDomains = Arrays.stream(corsProperties.getAppDomains().split(",")) .map(String::trim) @@ -57,7 +62,7 @@ public ServerService( @Async public void createServer(@NotNull Server server) { - serverRepository.saveEntity(server); + serverLookupRepository.saveEntity(server); evictAllServerCaches(); } @@ -85,7 +90,7 @@ public Server createServer(@NotNull String serverName, @NotNull String customDom server.setEmailVerificationToken(emailVerificationToken); } - Server saved = serverRepository.saveEntity(server); + Server saved = serverLookupRepository.saveEntity(server); evictAllServerCaches(); return saved; } @@ -100,10 +105,10 @@ public Server getServerFromDomain(@NotNull String domain) { String subdomain = extractSubdomain(key); if (subdomain != null) { - return serverRepository.findByCustomDomain(subdomain); + return serverLookupRepository.findByCustomDomain(subdomain); } - return serverRepository.findByActiveCustomDomainOverride(key); + return serverLookupRepository.findByActiveCustomDomainOverride(key); }).orElse(null); } @@ -123,11 +128,11 @@ public void evictApiKey(@NotNull String apiKey) { } public boolean isAdminEmailInUse(String adminEmail, String excludingServerId) { - return serverRepository.existsByAdminEmailExcludingId(EmailAddressUtil.normalize(adminEmail), excludingServerId); + return serverLookupRepository.existsByAdminEmailExcludingId(EmailAddressUtil.normalize(adminEmail), excludingServerId); } public void changeAdminEmail(Server server, String newAdminEmail) { - serverRepository.updateAdminEmail(server.getId(), EmailAddressUtil.normalize(newAdminEmail)); + serverCredentialRepository.updateAdminEmail(server.getId(), EmailAddressUtil.normalize(newAdminEmail)); evictAllServerCaches(); } @@ -165,7 +170,7 @@ public ServerExistResult doesServerExist(@NotNull String email, @NotNull String normalizedEmail = email; } - Server found = serverRepository.findMatchingIdentity(normalizedEmail, serverName, subdomain).orElse(null); + Server found = serverLookupRepository.findMatchingIdentity(normalizedEmail, serverName, subdomain).orElse(null); if (found == null) { return new ServerExistResult(false, false, false); } @@ -189,7 +194,7 @@ public ServerExistResult doesServerExist(@NotNull String email, @NotNull String @Nullable public Server getServerByDatabaseName(@NotNull String databaseName) { - return serverRepository.findByDatabaseName(databaseName).orElse(null); + return serverLookupRepository.findByDatabaseName(databaseName).orElse(null); } @Nullable @@ -199,35 +204,21 @@ public Server getServerByApiKey(@NotNull String apiKey) { } 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; + return apiKeyCache.get(cacheKey, key -> serverLookupRepository.findByApiKey(apiKey).orElse(null)); } 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); - } + return DigestUtils.sha256Hex(apiKey); } @Nullable public Server getServerByEmailVerificationToken(@NotNull String token) { - return serverRepository.findByEmailVerificationToken(token).orElse(null); + return serverLookupRepository.findByEmailVerificationToken(token).orElse(null); } @Nullable public Server verifyEmailToken(@NotNull String token) { - Server server = serverRepository.verifyEmailTokenAtomically(token).orElse(null); + Server server = serverProvisioningRepository.verifyEmailTokenAtomically(token).orElse(null); if (server == null) { return null; @@ -242,10 +233,10 @@ public Server verifyEmailToken(@NotNull String token) { } if (provisioned) { - serverRepository.markProvisioningCompleted(server.getId()); + serverProvisioningRepository.markProvisioningCompleted(server.getId()); server.setProvisioningStatus(ProvisioningStatus.COMPLETED); } else { - serverRepository.markProvisioningFailed(server.getId(), "Provisioning failed; awaiting retry."); + serverProvisioningRepository.markProvisioningFailed(server.getId(), Server.boundProvisioningNotes("Provisioning failed; awaiting retry.")); server.setProvisioningStatus(ProvisioningStatus.FAILED); } @@ -256,12 +247,12 @@ public Server verifyEmailToken(@NotNull String token) { @Nullable public Server getServerByAutoLoginToken(@NotNull String token) { - return serverRepository.findByProvisioningSignInToken(token).orElse(null); + return serverLookupRepository.findByProvisioningSignInToken(token).orElse(null); } @Nullable public Server consumeAutoLoginToken(@NotNull String token) { - Server server = serverRepository.consumeProvisioningSignInToken(token, new Date()).orElse(null); + Server server = serverProvisioningRepository.consumeProvisioningSignInToken(token, new Date()).orElse(null); if (server != null) { evictAllServerCaches(); } @@ -272,7 +263,7 @@ public Server setAutoLoginToken(@NotNull Server server, @NotNull String token, @ server.setProvisioningSignInToken(token); server.setProvisioningSignInTokenExpiresAt(expiresAt); server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); + Server saved = serverLookupRepository.saveEntity(server); evictAllServerCaches(); return saved; } @@ -281,20 +272,20 @@ public Server clearAutoLoginToken(@NotNull Server server) { server.setProvisioningSignInToken(null); server.setProvisioningSignInTokenExpiresAt(null); server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); + Server saved = serverLookupRepository.saveEntity(server); evictAllServerCaches(); return saved; } @Nullable public Server getServerByCliSetupToken(@NotNull String token) { - return serverRepository.findByCliSetupToken(token).orElse(null); + return serverLookupRepository.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); + Server saved = serverLookupRepository.saveEntity(server); evictAllServerCaches(); return saved; } @@ -302,7 +293,7 @@ public Server setCliSetupToken(@NotNull Server server, @NotNull String token) { public Server clearCliSetupToken(@NotNull Server server) { server.setCliSetupToken(null); server.setUpdatedAt(new Date()); - Server saved = serverRepository.saveEntity(server); + Server saved = serverLookupRepository.saveEntity(server); evictAllServerCaches(); return saved; } 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 eee7b38..d63f9d1 100644 --- a/src/main/java/gg/modl/backend/server/controller/PanelServerController.java +++ b/src/main/java/gg/modl/backend/server/controller/PanelServerController.java @@ -1,12 +1,11 @@ package gg.modl.backend.server.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; 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; @@ -14,14 +13,8 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_SERVER) -@RequiredArgsConstructor +@RequiresPanelPermission(view = "admin.settings.view", modify = "admin.settings.modify") 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) { 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 8420483..aa15aff 100644 --- a/src/main/java/gg/modl/backend/server/data/Server.java +++ b/src/main/java/gg/modl/backend/server/data/Server.java @@ -4,8 +4,10 @@ import gg.modl.backend.database.mongo.codegen.GenerateMongoFields; import gg.modl.backend.server.ServerField; import java.util.Date; -import lombok.Data; +import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; +import lombok.ToString; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.springframework.data.annotation.Id; @@ -15,7 +17,9 @@ import org.springframework.data.mongodb.core.mapping.FieldType; @Document(collection = CollectionName.MODL_SERVERS) -@Data +@Getter +@Setter +@ToString @RequiredArgsConstructor @GenerateMongoFields public class Server implements ServerBillingUpdate { @@ -44,6 +48,7 @@ public class Server implements ServerBillingUpdate { private Boolean emailVerified; @Nullable + @ToString.Exclude @Field(name = "emailVerificationToken", targetType = FieldType.STRING) private String emailVerificationToken; @@ -64,6 +69,7 @@ public class Server implements ServerBillingUpdate { private String provisioningNotes; @Nullable + @ToString.Exclude @Field(name = "provisioningSignInToken", targetType = FieldType.STRING) private String provisioningSignInToken; @@ -88,20 +94,22 @@ public class Server implements ServerBillingUpdate { private Date currentPeriodEnd; @Nullable + @ToString.Exclude @Field(name = "stripeCustomerId", targetType = FieldType.STRING) private String stripeCustomerId; @Nullable + @ToString.Exclude @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 + private Long aiRequestsCurrentPeriod; @Nullable @Field(name = "usageBillingEnabled", targetType = FieldType.BOOLEAN) - private Boolean usageBillingEnabled; // Whether to charge for overages + private Boolean usageBillingEnabled; @Nullable @Field(name = "usageBillingUpdatedAt", targetType = FieldType.DATE_TIME) @@ -121,7 +129,7 @@ public class Server implements ServerBillingUpdate { @Nullable @Field(name = "migrationFileSizeLimit", targetType = FieldType.INT64) - private Long migrationFileSizeLimit; // Custom migration file size limit in bytes + private Long migrationFileSizeLimit; @Nullable @Field(name = ServerField.CUSTOM_DOMAIN, targetType = FieldType.STRING) @@ -160,10 +168,12 @@ public class Server implements ServerBillingUpdate { private String betaTesterCreatedBy; @Nullable + @ToString.Exclude @Field(name = "cliSetupToken", targetType = FieldType.STRING) private String cliSetupToken; @Nullable + @ToString.Exclude @Field(name = "apiKey", targetType = FieldType.STRING) private String apiKey; @@ -202,4 +212,13 @@ public class Server implements ServerBillingUpdate { @Nullable @Field(name = "punishmentTypesUpdatedAt", targetType = FieldType.DATE_TIME) private Date punishmentTypesUpdatedAt; + + private static final int MAX_PROVISIONING_NOTES_LENGTH = 500; + + @Nullable + public static String boundProvisioningNotes(@Nullable String notes) { + return notes != null && notes.length() > MAX_PROVISIONING_NOTES_LENGTH + ? notes.substring(0, MAX_PROVISIONING_NOTES_LENGTH) + : notes; + } } diff --git a/src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java b/src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java new file mode 100644 index 0000000..dec4fd7 --- /dev/null +++ b/src/main/java/gg/modl/backend/server/service/PanelDomainResolver.java @@ -0,0 +1,21 @@ +package gg.modl.backend.server.service; + +import gg.modl.backend.infrastructure.config.ModlProperties; +import gg.modl.backend.server.data.Server; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PanelDomainResolver { + + private final ModlProperties modlProperties; + + public String panelDomain(Server server) { + String override = server.getCustomDomainOverride(); + if (override != null && !override.isBlank()) { + return override; + } + return server.getCustomDomain() + "." + modlProperties.getDomain(); + } +} diff --git a/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java b/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java index 7f3f3c3..4a7be61 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java +++ b/src/main/java/gg/modl/backend/server/service/ServerMutationHelper.java @@ -1,7 +1,7 @@ package gg.modl.backend.server.service; import gg.modl.backend.database.mongo.fields.ServerFields; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerAdminRepository; import gg.modl.backend.server.ServerService; import gg.modl.backend.server.data.Server; import gg.modl.backend.server.data.ServerBillingUpdate; @@ -16,7 +16,7 @@ @RequiredArgsConstructor public class ServerMutationHelper { - private final ServerMongoRepository serverRepository; + private final ServerAdminRepository serverAdminRepository; private final ServerService serverService; public void mutate(Server server, Consumer mutator) { @@ -87,7 +87,7 @@ public void mutate(Server server, Consumer mutator) { } update.set(ServerFields.UPDATED_AT, new Date()); - serverRepository.applyFieldUpdate(server.getId(), update); + serverAdminRepository.applyFieldUpdate(server.getId(), update); serverService.evictAllServerCaches(); } } 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 1f6baef..0255055 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java +++ b/src/main/java/gg/modl/backend/server/service/ServerProvisioningService.java @@ -1,6 +1,6 @@ package gg.modl.backend.server.service; -import gg.modl.backend.database.MongoIndexBootstrapService; +import gg.modl.backend.database.MongoIndexReconciler; import gg.modl.backend.database.mongo.TenantMongoAccess; import gg.modl.backend.database.mongo.repository.HomepageCardMongoRepository; import gg.modl.backend.database.mongo.repository.KnowledgebaseCategoryMongoRepository; @@ -28,7 +28,7 @@ public class ServerProvisioningService { private static final String AI_ANTI_SOCIAL_CONFIG_ID = "7"; private final TenantMongoAccess tenantMongoAccess; - private final MongoIndexBootstrapService mongoIndexBootstrapService; + private final MongoIndexReconciler mongoIndexReconciler; private final SettingsMongoRepository settingsRepository; private final KnowledgebaseCategoryMongoRepository knowledgebaseCategoryRepository; private final HomepageCardMongoRepository homepageCardRepository; @@ -57,9 +57,33 @@ public void provision(Server server) { } } + public boolean tenantDatabaseExists(Server server) { + try { + return !tenantMongoAccess.forServer(server).getCollectionNames().isEmpty(); + } catch (Exception e) { + log.warn("[Provisioning] Could not determine whether tenant database '{}' already exists for {}; assuming it does", + server.getDatabaseName(), server.getCustomDomain(), e); + return true; + } + } + + public void teardownProvisionedDatabase(Server server, boolean databaseExistedBeforeProvisioning) { + if (databaseExistedBeforeProvisioning) { + return; + } + try { + tenantMongoAccess.forServer(server).getDb().drop(); + log.warn("[Provisioning] Dropped tenant database '{}' created by a failed provisioning attempt for {}", + server.getDatabaseName(), server.getCustomDomain()); + } catch (Exception e) { + log.error("[Provisioning] Failed to drop tenant database '{}' after a failed provisioning attempt for {}", + server.getDatabaseName(), server.getCustomDomain(), e); + } + } + private Map provisioningSteps(Server server) { Map steps = new LinkedHashMap<>(); - steps.put("createTenantIndexes", () -> mongoIndexBootstrapService.createTenantIndexes(tenantMongoAccess.forServer(server))); + steps.put("createTenantIndexes", () -> mongoIndexReconciler.createTenantIndexes(tenantMongoAccess.forServer(server))); steps.put("seedAIModerationSettings", () -> seedAIModerationSettings(server)); steps.put("seedTicketForms", () -> seedTicketForms(server)); steps.put("seedQuickResponses", () -> seedQuickResponses(server)); @@ -115,7 +139,6 @@ private void seedTicketForms(Server server) { 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"), @@ -128,7 +151,6 @@ private void seedTicketForms(Server server) { 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"), @@ -138,7 +160,6 @@ private void seedTicketForms(Server server) { formSection("1753243900648", "General", 0, false) )); - // Application form Map applicationForm = buildApplicationForm(); Map data = new LinkedHashMap<>(); @@ -152,7 +173,6 @@ private void seedTicketForms(Server server) { 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")); @@ -163,7 +183,6 @@ private Map buildApplicationForm() { 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"); @@ -181,7 +200,6 @@ private Map buildApplicationForm() { )); 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.", @@ -207,19 +225,16 @@ private Map buildApplicationForm() { "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")); @@ -272,7 +287,6 @@ private void seedQuickResponses(Server server) { 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, @@ -285,7 +299,6 @@ private void seedQuickResponses(Server server) { 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, @@ -298,7 +311,6 @@ private void seedQuickResponses(Server server) { 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, @@ -312,7 +324,6 @@ private void seedQuickResponses(Server server) { "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.", @@ -335,7 +346,6 @@ private void seedQuickResponses(Server server) { 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), @@ -351,7 +361,6 @@ private void seedQuickResponses(Server server) { "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), @@ -361,7 +370,6 @@ private void seedQuickResponses(Server server) { "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( @@ -403,8 +411,6 @@ private Map quickResponseAction(String id, String name, String m return action; } - // Helper methods for building form data structures - private void seedGeneralSettings(Server server) { if (settingsExist(server, "general")) { return; @@ -496,7 +502,6 @@ private void seedHomepageCards(Server server) { 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); diff --git a/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java b/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java index a5cc109..3ae33f5 100644 --- a/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java +++ b/src/main/java/gg/modl/backend/server/service/ServerTimestampService.java @@ -1,6 +1,6 @@ package gg.modl.backend.server.service; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerSettingsTimestampRepository; import gg.modl.backend.realtime.dispatch.RealtimeEventDispatcher; import gg.modl.backend.realtime.dispatch.RealtimeOutboundEvent; import gg.modl.backend.server.data.Server; @@ -9,6 +9,7 @@ import gg.modl.proto.modl.v1.RealtimeEnvelope; import gg.modl.proto.modl.v1.Topic; import java.util.Date; +import java.util.function.Consumer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; @@ -21,41 +22,37 @@ public class ServerTimestampService { private static final String STAFF_PERMISSIONS_TIMESTAMP_FIELD = "staffPermissionsUpdatedAt"; private static final String PUNISHMENT_TYPES_TIMESTAMP_FIELD = "punishmentTypesUpdatedAt"; - private final ServerMongoRepository serverRepository; + private final ServerSettingsTimestampRepository serverSettingsTimestampRepository; private final RealtimeEventDispatcher realtimeEventDispatcher; private long lastTimestampMillis; public void updateStaffPermissionsTimestamp(@NotNull Server server) { Date timestamp = nextTimestamp(); - serverRepository.updateStaffPermissionsTimestamp(server.getId(), timestamp); - try { - realtimeEventDispatcher.publish(new RealtimeOutboundEvent( - server.getId(), - Topic.TOPIC_MINECRAFT_PERMISSIONS, - RealtimeEnvelope.newBuilder() - .setEventId(eventId(server, STAFF_PERMISSIONS_TIMESTAMP_FIELD, timestamp)) - .setPermissionInvalidated(PermissionInvalidatedEvent.newBuilder()) - .build() - )); - } catch (Exception e) { - log.warn("Failed to dispatch realtime staff permissions invalidation", e); - } + serverSettingsTimestampRepository.updateStaffPermissionsTimestamp(server.getId(), timestamp); + publishInvalidation(server, timestamp, STAFF_PERMISSIONS_TIMESTAMP_FIELD, + Topic.TOPIC_MINECRAFT_PERMISSIONS, + envelope -> envelope.setPermissionInvalidated(PermissionInvalidatedEvent.newBuilder()), + "Failed to dispatch realtime staff permissions invalidation"); } public void updatePunishmentTypesTimestamp(@NotNull Server server) { Date timestamp = nextTimestamp(); - serverRepository.updatePunishmentTypesTimestamp(server.getId(), timestamp); + serverSettingsTimestampRepository.updatePunishmentTypesTimestamp(server.getId(), timestamp); + publishInvalidation(server, timestamp, PUNISHMENT_TYPES_TIMESTAMP_FIELD, + Topic.TOPIC_MINECRAFT_PUNISHMENT_TYPES, + envelope -> envelope.setPunishmentTypeInvalidated(PunishmentTypeInvalidatedEvent.newBuilder()), + "Failed to dispatch realtime punishment type invalidation"); + } + + private void publishInvalidation(Server server, Date timestamp, String timestampField, Topic topic, + Consumer payloadSetter, String failureMessage) { try { - realtimeEventDispatcher.publish(new RealtimeOutboundEvent( - server.getId(), - Topic.TOPIC_MINECRAFT_PUNISHMENT_TYPES, - RealtimeEnvelope.newBuilder() - .setEventId(eventId(server, PUNISHMENT_TYPES_TIMESTAMP_FIELD, timestamp)) - .setPunishmentTypeInvalidated(PunishmentTypeInvalidatedEvent.newBuilder()) - .build() - )); + RealtimeEnvelope.Builder envelope = RealtimeEnvelope.newBuilder() + .setEventId(eventId(server, timestampField, timestamp)); + payloadSetter.accept(envelope); + realtimeEventDispatcher.publish(new RealtimeOutboundEvent(server.getId(), topic, envelope.build())); } catch (Exception e) { - log.warn("Failed to dispatch realtime punishment type invalidation", e); + log.warn(failureMessage, e); } } diff --git a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java index 8db2477..d390847 100644 --- a/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java +++ b/src/main/java/gg/modl/backend/settings/controller/MinecraftPunishmentTypeProtoMapper.java @@ -1,5 +1,8 @@ package gg.modl.backend.settings.controller; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; +import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; + import com.google.protobuf.Struct; import com.google.protobuf.Value; import gg.modl.backend.settings.data.DurationDetail; @@ -113,12 +116,4 @@ private static void putNumber(Struct.Builder builder, String key, int value) { private static void putString(Struct.Builder builder, String key, String value) { builder.putFields(key, Value.newBuilder().setStringValue(stringValue(value)).build()); } - - private static int intValue(Integer value) { - return value == null ? 0 : value; - } - - private static String stringValue(String value) { - return value == null ? "" : value; - } } diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java b/src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java new file mode 100644 index 0000000..7d844b8 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/controller/PanelAiSuggestionController.java @@ -0,0 +1,54 @@ +package gg.modl.backend.settings.controller; + +import gg.modl.backend.ai.service.AITicketAnalysisService; +import gg.modl.backend.infrastructure.authorization.PanelAccessRule; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +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.AISuggestionActionResponse; +import gg.modl.proto.modl.v1.ApplyAIPunishmentRequest; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +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) +@RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") +@RequiredArgsConstructor +public class PanelAiSuggestionController { + private final AITicketAnalysisService aiTicketAnalysisService; + + @PostMapping("/ai-apply-punishment/{ticketId}") + @RequiresPanelPermission(rule = PanelAccessRule.PERMIT_ALL) + public AISuggestionActionResponse applyAIPunishment( + @PathVariable String ticketId, + @RequestBody ApplyAIPunishmentRequest body, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + String email = RequestUtil.getSessionEmail(request); + + AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.applyAISuggestion(server, ticketId, email); + return toAISuggestionResponse(result); + } + + @PostMapping("/ai-dismiss-suggestion/{ticketId}") + public AISuggestionActionResponse dismissAISuggestion( + @PathVariable String ticketId, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + + AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.dismissAISuggestion(server, ticketId); + return toAISuggestionResponse(result); + } + + private AISuggestionActionResponse toAISuggestionResponse(AITicketAnalysisService.AISuggestionResult result) { + return PanelSettingsProtoMapper.toAISuggestionActionResponse(result.success(), result.error(), null); + } +} diff --git a/src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java b/src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java new file mode 100644 index 0000000..ca2e04c --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/controller/PanelApiKeyController.java @@ -0,0 +1,95 @@ +package gg.modl.backend.settings.controller; + +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; +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.role.service.PermissionService; +import gg.modl.backend.server.data.Server; +import gg.modl.backend.settings.service.ApiKeySettingsService; +import gg.modl.proto.modl.v1.ApiKeyDeleteResponse; +import gg.modl.proto.modl.v1.ApiKeyExistsResponse; +import gg.modl.proto.modl.v1.ApiKeyGenerateResponse; +import gg.modl.proto.modl.v1.ApiKeyRevealResponse; +import jakarta.servlet.http.HttpServletRequest; +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.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(RESTMappingV1.PANEL_SETTINGS) +@RequiresPanelPermission(view = "admin.settings.view", modify = "admin.settings.modify") +@RequiredArgsConstructor +public class PanelApiKeyController { + private final ApiKeySettingsService apiKeySettingsService; + private final PermissionService permissionService; + private final SettingsInvalidationPublisher settingsInvalidationPublisher; + + @PostMapping("/api-keys/{type}/generate") + public ApiKeyGenerateResponse generateApiKey( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String apiKey = apiKeySettingsService.generateApiKey(server, type); + settingsInvalidationPublisher.invalidateSettings(server); + return PanelSettingsProtoMapper.toApiKeyGenerateResponse("API key generated successfully", apiKey); + } + + @GetMapping("/api-keys/{type}/reveal") + public ResponseEntity revealApiKey( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + String apiKey = apiKeySettingsService.revealApiKey(server, type); + + if (apiKey == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyRevealResponse(apiKey)); + } + + @DeleteMapping("/api-keys/{type}") + public ResponseEntity deleteApiKey( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + boolean deleted = apiKeySettingsService.deleteApiKey(server, type); + + if (!deleted) { + return ResponseEntity.notFound().build(); + } + + settingsInvalidationPublisher.invalidateSettings(server); + return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyDeleteResponse("API key deleted successfully")); + } + + @GetMapping("/api-keys/{type}/exists") + public ApiKeyExistsResponse checkApiKeyExists( + @PathVariable String type, + HttpServletRequest request + ) { + Server server = RequestUtil.getRequestServer(request); + requireSuperAdmin(server, request); + boolean exists = apiKeySettingsService.hasApiKey(server, type); + return PanelSettingsProtoMapper.toApiKeyExistsResponse(exists); + } + + private void requireSuperAdmin(Server server, HttpServletRequest request) { + String email = RequestUtil.getSessionEmail(request); + if (!permissionService.isSuperAdmin(server, email)) { + throw new ForbiddenException("Only super admins can manage API keys"); + } + } +} 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 e28ec1c..cf11d9c 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelDomainSettingsController.java @@ -1,15 +1,14 @@ package gg.modl.backend.settings.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; 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; @@ -24,11 +23,12 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/domain") +@RequiresPanelPermission(view = "admin.settings.view.domain", modify = "admin.settings.modify.domain") @RequiredArgsConstructor public class PanelDomainSettingsController { private final DomainSettingsService domainSettingsService; private final CustomDomainAccessService customDomainAccessService; - private final RealtimeEventPublisher realtimeEventPublisher; + private final SettingsInvalidationPublisher settingsInvalidationPublisher; @GetMapping public gg.modl.proto.modl.v1.DomainSettings getDomainSettings(HttpServletRequest request) { @@ -47,7 +47,7 @@ public gg.modl.proto.modl.v1.DomainSettings configureDomain( requireCustomDomainWriteAccess(server); DomainSettings settings = domainSettingsService.configureDomain(server, body.getCustomDomain().trim()); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toDomainSettings(settings); } @@ -78,7 +78,7 @@ public VerifyDomainResponse verifyDomain( default -> "Domain verification pending. Please ensure your CNAME is configured correctly."; }; - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toVerifyDomainResponse(settings, message); } @@ -88,11 +88,7 @@ public RemoveDomainResponse removeDomain(HttpServletRequest request) { requireCustomDomainWriteAccess(server); domainSettingsService.removeDomain(server); - invalidateSettings(server); + settingsInvalidationPublisher.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 5610efe..d03a41d 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelPunishmentTypeController.java @@ -1,22 +1,22 @@ package gg.modl.backend.settings.controller; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; 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.PunishmentCategory; 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; @@ -30,12 +30,13 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_SETTINGS + "/punishment-types") +@RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") @RequiredArgsConstructor public class PanelPunishmentTypeController { private final PunishmentTypeService punishmentTypeService; private final RealtimeEventPublisher realtimeEventPublisher; private final PermissionService permissionService; - private final Validator validator; + private final BeanValidationRunner validationRunner; @GetMapping public PanelPunishmentTypesResponse getPunishmentTypes(HttpServletRequest request) { @@ -64,7 +65,7 @@ public gg.modl.proto.modl.v1.PunishmentType updatePunishmentType( ) { Server server = RequestUtil.getRequestServer(request); PunishmentType updatedType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); - validate(updatedType); + validationRunner.validate(updatedType); String previousName = punishmentTypeService.getPunishmentTypeByOrdinal(server, ordinal) .map(PunishmentType::getName) @@ -87,7 +88,7 @@ public gg.modl.proto.modl.v1.PunishmentType createPunishmentType( ) { Server server = RequestUtil.getRequestServer(request); PunishmentType newType = PanelSettingsProtoMapper.fromPunishmentTypeRequest(requestBody); - validate(newType); + validationRunner.validate(newType); PunishmentType created = punishmentTypeService.createPunishmentType(server, newType); invalidatePunishmentTypes(server, created.getOrdinal()); return PanelSettingsProtoMapper.toPunishmentType(created); @@ -108,7 +109,7 @@ public ResponseEntity deletePunishmentType( ) { Server server = RequestUtil.getRequestServer(request); - if (ordinal < 6) { + if (ordinal <= PunishmentCategory.MAX_CORE_ORDINAL) { throw new ValidationException("Cannot delete core administrative punishment types"); } @@ -128,10 +129,4 @@ private void invalidatePunishmentTypes(Server server, Integer ordinal) { ); } - 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/PanelSettingsController.java b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java index d9acc3a..c0947f3 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsController.java @@ -1,12 +1,10 @@ package gg.modl.backend.settings.controller; -import gg.modl.backend.ai.service.AITicketAnalysisService; -import gg.modl.backend.infrastructure.exception.ForbiddenException; +import gg.modl.backend.infrastructure.authorization.RequiresPanelPermission; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.infrastructure.validation.BeanValidationRunner; 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.AIModerationSettings; import gg.modl.backend.settings.data.GeneralSettings; @@ -19,7 +17,6 @@ import gg.modl.backend.settings.dto.request.PatchReplayRetentionSettingsRequest; import gg.modl.backend.settings.dto.request.UpdateQuickResponsesRequest; import gg.modl.backend.settings.service.AIModerationSettingsService; -import gg.modl.backend.settings.service.ApiKeySettingsService; import gg.modl.backend.settings.service.GeneralSettingsService; import gg.modl.backend.settings.service.IconUploadService; import gg.modl.backend.settings.service.OffenderThresholdSettingsService; @@ -29,14 +26,8 @@ import gg.modl.backend.settings.service.TicketLabelSettingsService; import gg.modl.backend.settings.service.VersionedSettings; import gg.modl.backend.settings.service.WebhookSettingsService; -import gg.modl.proto.modl.v1.AISuggestionActionResponse; -import gg.modl.proto.modl.v1.ApiKeyDeleteResponse; -import gg.modl.proto.modl.v1.ApiKeyExistsResponse; -import gg.modl.proto.modl.v1.ApiKeyGenerateResponse; -import gg.modl.proto.modl.v1.ApiKeyRevealResponse; import gg.modl.proto.modl.v1.GeneralSettingsEnvelope; import gg.modl.proto.modl.v1.OffenderThresholdSettingsEnvelope; -import gg.modl.proto.modl.v1.PanelResource; import gg.modl.proto.modl.v1.PatchGeneralSettingsRequest; import gg.modl.proto.modl.v1.PatchQuickResponsesRequest; import gg.modl.proto.modl.v1.PatchStatusThresholdSettingsRequest; @@ -51,13 +42,9 @@ import gg.modl.proto.modl.v1.UpdateWebhookSettingsRequest; import gg.modl.proto.modl.v1.WebhookTestResponse; import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; -import jakarta.validation.Validator; -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; @@ -70,22 +57,20 @@ @RestController @RequestMapping(RESTMappingV1.PANEL_SETTINGS) +@RequiresPanelPermission(view = "admin.settings.view", modify = "admin.settings.modify") @RequiredArgsConstructor public class PanelSettingsController { private final GeneralSettingsService generalSettingsService; private final TicketLabelSettingsService ticketLabelSettingsService; - private final ApiKeySettingsService apiKeySettingsService; private final AIModerationSettingsService aiModerationSettingsService; private final WebhookSettingsService webhookSettingsService; private final TicketFormSettingsService ticketFormSettingsService; private final QuickResponseSettingsService quickResponseSettingsService; private final IconUploadService iconUploadService; - private final AITicketAnalysisService aiTicketAnalysisService; private final OffenderThresholdSettingsService offenderThresholdSettingsService; - private final PermissionService permissionService; private final ReplayRetentionSettingsService replayRetentionSettingsService; - private final RealtimeEventPublisher realtimeEventPublisher; - private final Validator validator; + private final SettingsInvalidationPublisher settingsInvalidationPublisher; + private final BeanValidationRunner validationRunner; @GetMapping("/general") public GeneralSettingsEnvelope getGeneralSettings(HttpServletRequest request) { @@ -104,7 +89,7 @@ public GeneralSettingsEnvelope patchGeneralSettings( body.getExpectedVersion(), PanelSettingsProtoMapper.fromPatchGeneralSettingsRequest(body) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toGeneralSettingsEnvelope(updated); } @@ -126,11 +111,12 @@ public TicketLabelSettingsEnvelope patchTicketLabelSettings( body.getExpectedVersion(), PanelSettingsProtoMapper.fromPatchTicketLabelSettingsRequest(body) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toTicketLabelSettingsEnvelope(updated); } @GetMapping("/status-thresholds") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public OffenderThresholdSettingsEnvelope getStatusThresholds(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); return PanelSettingsProtoMapper.toOffenderThresholdSettingsEnvelope( @@ -138,6 +124,7 @@ public OffenderThresholdSettingsEnvelope getStatusThresholds(HttpServletRequest } @PatchMapping("/status-thresholds") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public OffenderThresholdSettingsEnvelope patchStatusThresholds( @RequestBody PatchStatusThresholdSettingsRequest body, HttpServletRequest request @@ -148,7 +135,7 @@ public OffenderThresholdSettingsEnvelope patchStatusThresholds( body.getExpectedVersion(), PanelSettingsProtoMapper.fromOffenderThresholdSettings(body.getSettings()) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toOffenderThresholdSettingsEnvelope(updated); } @@ -171,67 +158,12 @@ public ReplayRetentionSettingsEnvelope patchReplayRetentionSettings( body.enabled(), body.days() ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toReplayRetentionSettingsEnvelope(updated); } - @PostMapping("/api-keys/{type}/generate") - public ApiKeyGenerateResponse generateApiKey( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String apiKey = apiKeySettingsService.generateApiKey(server, type); - invalidateSettings(server); - return PanelSettingsProtoMapper.toApiKeyGenerateResponse("API key generated successfully", apiKey); - } - - @GetMapping("/api-keys/{type}/reveal") - public ResponseEntity revealApiKey( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - String apiKey = apiKeySettingsService.revealApiKey(server, type); - - if (apiKey == null) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyRevealResponse(apiKey)); - } - - @DeleteMapping("/api-keys/{type}") - public ResponseEntity deleteApiKey( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - boolean deleted = apiKeySettingsService.deleteApiKey(server, type); - - if (!deleted) { - return ResponseEntity.notFound().build(); - } - - invalidateSettings(server); - return ResponseEntity.ok(PanelSettingsProtoMapper.toApiKeyDeleteResponse("API key deleted successfully")); - } - - @GetMapping("/api-keys/{type}/exists") - public ApiKeyExistsResponse checkApiKeyExists( - @PathVariable String type, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - requireSuperAdmin(server, request); - boolean exists = apiKeySettingsService.hasApiKey(server, type); - return PanelSettingsProtoMapper.toApiKeyExistsResponse(exists); - } - @GetMapping("/ai-moderation") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public gg.modl.proto.modl.v1.AIModerationSettings getAIModerationSettings(HttpServletRequest request) { Server server = RequestUtil.getRequestServer(request); AIModerationSettings settings = aiModerationSettingsService.getAIModerationSettings(server); @@ -239,15 +171,16 @@ public gg.modl.proto.modl.v1.AIModerationSettings getAIModerationSettings(HttpSe } @PatchMapping("/ai-moderation") + @RequiresPanelPermission(view = "admin.settings.view.punishments", modify = "admin.settings.modify.punishments") public gg.modl.proto.modl.v1.AIModerationSettings updateAIModerationSettings( @RequestBody UpdateAIModerationSettingsRequest requestBody, HttpServletRequest request ) { Server server = RequestUtil.getRequestServer(request); AIModerationSettings settings = PanelSettingsProtoMapper.fromUpdateAIModerationSettingsRequest(requestBody); - validate(settings); + validationRunner.validate(settings); AIModerationSettings updated = aiModerationSettingsService.updateAIModerationSettings(server, settings); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toAIModerationSettings(updated); } @@ -266,7 +199,7 @@ public gg.modl.proto.modl.v1.WebhookSettings updateWebhookSettings( Server server = RequestUtil.getRequestServer(request); WebhookSettings settings = PanelSettingsProtoMapper.fromUpdateWebhookSettingsRequest(requestBody); WebhookSettings updated = webhookSettingsService.updateWebhookSettings(server, settings); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toWebhookSettings(updated); } @@ -299,7 +232,7 @@ public TicketFormSettingsEnvelope patchTicketFormSettings( body.getExpectedVersion(), PanelSettingsProtoMapper.fromTicketFormSettings(body.getSettings()) ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toTicketFormSettingsEnvelope(updated); } @@ -333,13 +266,13 @@ public QuickResponseSettingsEnvelope patchQuickResponses( Server server = RequestUtil.getRequestServer(request); UpdateQuickResponsesRequest quickResponses = new UpdateQuickResponsesRequest( PanelSettingsProtoMapper.fromPatchQuickResponsesRequest(body)); - validate(quickResponses); + validationRunner.validate(quickResponses); VersionedSettings updated = quickResponseSettingsService.patchQuickResponseSettings( server, body.getExpectedVersion(), quickResponses ); - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); return PanelSettingsProtoMapper.toQuickResponseSettingsEnvelope(updated); } @@ -352,55 +285,8 @@ public ResponseEntity uploadIcon( Server server = RequestUtil.getRequestServer(request); ResponseEntity response = iconUploadService.uploadIcon(server, file, iconType); if (response.getStatusCode().is2xxSuccessful()) { - invalidateSettings(server); + settingsInvalidationPublisher.invalidateSettings(server); } return response; } - - @PostMapping("/ai-apply-punishment/{ticketId}") - public AISuggestionActionResponse applyAIPunishment( - @PathVariable String ticketId, - @RequestBody gg.modl.proto.modl.v1.ApplyAIPunishmentRequest body, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - String email = RequestUtil.getSessionEmail(request); - - AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.applyAISuggestion(server, ticketId, email); - return toAISuggestionResponse(result); - } - - @PostMapping("/ai-dismiss-suggestion/{ticketId}") - public AISuggestionActionResponse dismissAISuggestion( - @PathVariable String ticketId, - HttpServletRequest request - ) { - Server server = RequestUtil.getRequestServer(request); - - AITicketAnalysisService.AISuggestionResult result = aiTicketAnalysisService.dismissAISuggestion(server, ticketId); - return toAISuggestionResponse(result); - } - - private AISuggestionActionResponse toAISuggestionResponse(AITicketAnalysisService.AISuggestionResult result) { - return PanelSettingsProtoMapper.toAISuggestionActionResponse(result.success(), result.error(), null); - } - - private void invalidateSettings(Server server) { - realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); - } - - private void requireSuperAdmin(Server server, HttpServletRequest request) { - String email = RequestUtil.getSessionEmail(request); - if (!permissionService.isSuperAdmin(server, email)) { - throw new ForbiddenException("Only super admins can manage API keys"); - } - } - - 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/PanelSettingsProtoMapper.java b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java index efb6a2a..2c01aa1 100644 --- a/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java +++ b/src/main/java/gg/modl/backend/settings/controller/PanelSettingsProtoMapper.java @@ -1,7 +1,5 @@ package gg.modl.backend.settings.controller; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.booleanValue; -import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.intValue; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalBoolean; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.setOptionalInt; import static gg.modl.backend.infrastructure.proto.ProtoMapperSupport.stringValue; @@ -44,8 +42,10 @@ import gg.modl.proto.modl.v1.TicketLabelSettingsEnvelope; import gg.modl.proto.modl.v1.VerifyDomainResponse; import gg.modl.proto.modl.v1.WebhookTestResponse; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; final class PanelSettingsProtoMapper { @@ -734,11 +734,11 @@ static AIModerationSettings fromUpdateAIModerationSettingsRequest( .enableAutomatedActions(request.getEnableAutomatedActions()); Map configs = request.getAiPunishmentConfigsMap().entrySet().stream() - .collect(java.util.stream.Collectors.toMap( + .collect(Collectors.toMap( Map.Entry::getKey, entry -> fromAIPunishmentConfigRequest(entry.getValue()), (left, right) -> right, - java.util.LinkedHashMap::new + LinkedHashMap::new )); return builder.aiPunishmentConfigs(configs).build(); } diff --git a/src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java b/src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java new file mode 100644 index 0000000..1ebbf53 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/controller/SettingsInvalidationPublisher.java @@ -0,0 +1,17 @@ +package gg.modl.backend.settings.controller; + +import gg.modl.backend.realtime.publish.RealtimeEventPublisher; +import gg.modl.backend.server.data.Server; +import gg.modl.proto.modl.v1.PanelResource; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +class SettingsInvalidationPublisher { + private final RealtimeEventPublisher realtimeEventPublisher; + + void invalidateSettings(Server server) { + realtimeEventPublisher.invalidatePanel(server, PanelResource.PANEL_RESOURCE_SETTINGS); + } +} 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 62fd430..41b9fd2 100644 --- a/src/main/java/gg/modl/backend/settings/data/DomainSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/DomainSettings.java @@ -23,10 +23,10 @@ public class DomainSettings { public static class DomainStatus { private String domain; @Builder.Default - private String status = "pending"; // pending, active, error, verifying + private String status = "pending"; private boolean cnameConfigured; @Builder.Default - private String sslStatus = "pending"; // pending, active, error + private String sslStatus = "pending"; 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 089d05e..d00d58c 100644 --- a/src/main/java/gg/modl/backend/settings/data/DurationDetail.java +++ b/src/main/java/gg/modl/backend/settings/data/DurationDetail.java @@ -16,14 +16,12 @@ public record DurationDetail( 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 -1L; } return switch (unit.toLowerCase()) { @@ -33,7 +31,7 @@ public long toMilliseconds() { 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 + default -> -1L; }; } 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 5e8e667..a8174f3 100644 --- a/src/main/java/gg/modl/backend/settings/data/Label.java +++ b/src/main/java/gg/modl/backend/settings/data/Label.java @@ -10,8 +10,8 @@ @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 + private String id; + private String name; + private String color; + private String 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 e140a3a..22b74f0 100644 --- a/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java +++ b/src/main/java/gg/modl/backend/settings/data/OffenderThresholdSettings.java @@ -7,28 +7,15 @@ 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); @@ -36,23 +23,14 @@ 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); } @@ -61,22 +39,10 @@ public String getOffenseLevelInternal(int points, boolean isSocial) { @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) { @@ -91,20 +57,11 @@ public CategoryThresholds(int medium, int habitual, int pointExpiryMonths) { 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"; @@ -115,12 +72,6 @@ public String getOffenderLevel(int points) { } } - /** - * 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"; diff --git a/src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java b/src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java new file mode 100644 index 0000000..2ce1357 --- /dev/null +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentCategory.java @@ -0,0 +1,30 @@ +package gg.modl.backend.settings.data; + +import org.jetbrains.annotations.Nullable; + +public enum PunishmentCategory { + KICK, + MUTE, + BAN; + + public static final int MIN_CORE_ORDINAL = 0; + public static final int MAX_CORE_ORDINAL = 5; + + public static boolean isCoreOrdinal(@Nullable Integer ordinal) { + return ordinal != null && ordinal >= MIN_CORE_ORDINAL && ordinal <= MAX_CORE_ORDINAL; + } + + @Nullable + public static PunishmentCategory fromCoreOrdinal(@Nullable Integer ordinal) { + if (!isCoreOrdinal(ordinal)) { + return null; + } + if (ordinal == 0) { + return KICK; + } + if (ordinal == 1) { + return MUTE; + } + return BAN; + } +} 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 ebec9a7..730aa5e 100644 --- a/src/main/java/gg/modl/backend/settings/data/PunishmentType.java +++ b/src/main/java/gg/modl/backend/settings/data/PunishmentType.java @@ -63,23 +63,25 @@ public boolean isAdministrative() { } public boolean isBan() { - // Core types (ordinals 0-5) are hardcoded - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal >= 2; + PunishmentCategory coreCategory = PunishmentCategory.fromCoreOrdinal(ordinal); + if (coreCategory != null) { + return coreCategory == PunishmentCategory.BAN; } return category != null && category.toLowerCase().contains("ban"); } public boolean isMute() { - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal == 1; + PunishmentCategory coreCategory = PunishmentCategory.fromCoreOrdinal(ordinal); + if (coreCategory != null) { + return coreCategory == PunishmentCategory.MUTE; } return category != null && category.toLowerCase().contains("mute"); } public boolean isKick() { - if (ordinal != null && ordinal >= 0 && ordinal <= 5) { - return ordinal == 0; + PunishmentCategory coreCategory = PunishmentCategory.fromCoreOrdinal(ordinal); + if (coreCategory != null) { + return coreCategory == PunishmentCategory.KICK; } return category != null && category.toLowerCase().contains("kick"); } diff --git a/src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java b/src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java deleted file mode 100644 index bff8833..0000000 --- a/src/main/java/gg/modl/backend/settings/dto/request/PunishmentTypeRequest.java +++ /dev/null @@ -1,72 +0,0 @@ -package gg.modl.backend.settings.dto.request; - -import gg.modl.backend.settings.data.AppealForm; -import gg.modl.backend.settings.data.OffenseLevelDurations; -import gg.modl.backend.settings.data.PunishmentDurations; -import gg.modl.backend.settings.data.PunishmentPoints; -import gg.modl.backend.settings.data.PunishmentType; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -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; - -public record PunishmentTypeRequest( - @NotBlank - @Size( - min = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MIN_LENGTH, - max = RequestValidationLimits.PUNISHMENT_TYPE_NAME_MAX_LENGTH - ) - String name, - @NotBlank - @Pattern(regexp = "(?i)Administrative|Social|Gameplay", message = "must be one of: Administrative, Social, Gameplay") - @Size(max = RequestValidationLimits.PUNISHMENT_CATEGORY_MAX_LENGTH) - String category, - @Valid - PunishmentDurations durations, - @Valid - OffenseLevelDurations singleSeverityDurations, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - Integer singleSeverityPoints, - @Valid - PunishmentPoints points, - @Min(RequestValidationLimits.PUNISHMENT_POINTS_MIN) - @Max(RequestValidationLimits.PUNISHMENT_POINTS_MAX) - Integer customPoints, - @Size(max = RequestValidationLimits.PUNISHMENT_STAFF_DESCRIPTION_MAX_LENGTH) - String staffDescription, - @Size(max = RequestValidationLimits.PUNISHMENT_DESCRIPTION_MAX_LENGTH) - String playerDescription, - Boolean singleSeverityPunishment, - Boolean canBeAltBlocking, - Boolean canBeStatWiping, - Boolean appealable, - @Valid - AppealForm appealForm, - Boolean permanentUntilSkinChange, - Boolean permanentUntilUsernameChange -) { - public PunishmentType toPunishmentType() { - PunishmentType punishmentType = new PunishmentType(); - punishmentType.setName(name); - punishmentType.setCategory(category); - punishmentType.setDurations(durations); - punishmentType.setSingleSeverityDurations(singleSeverityDurations); - punishmentType.setSingleSeverityPoints(singleSeverityPoints); - punishmentType.setPoints(points); - punishmentType.setCustomPoints(customPoints); - punishmentType.setStaffDescription(staffDescription); - punishmentType.setPlayerDescription(playerDescription); - punishmentType.setSingleSeverityPunishment(singleSeverityPunishment); - punishmentType.setCanBeAltBlocking(canBeAltBlocking); - punishmentType.setCanBeStatWiping(canBeStatWiping); - punishmentType.setAppealable(appealable); - punishmentType.setAppealForm(appealForm); - punishmentType.setPermanentUntilSkinChange(permanentUntilSkinChange); - punishmentType.setPermanentUntilUsernameChange(permanentUntilUsernameChange); - return punishmentType; - } -} diff --git a/src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java b/src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java deleted file mode 100644 index 106e414..0000000 --- a/src/main/java/gg/modl/backend/settings/dto/request/UpdateWebhookSettingsRequest.java +++ /dev/null @@ -1,100 +0,0 @@ -package gg.modl.backend.settings.dto.request; - -import gg.modl.backend.settings.data.WebhookSettings; -import gg.modl.backend.infrastructure.validation.RequestValidationLimits; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.Size; -import java.util.List; - -public record UpdateWebhookSettingsRequest( - @Size(max = RequestValidationLimits.WEBHOOK_URL_MAX_LENGTH) - String discordWebhookUrl, - @Size(max = RequestValidationLimits.DISCORD_ADMIN_ROLE_ID_MAX_LENGTH) - String discordAdminRoleId, - @Size(max = RequestValidationLimits.DISCORD_BOT_NAME_MAX_LENGTH) - String botName, - @Size(max = RequestValidationLimits.WEBHOOK_URL_MAX_LENGTH) - String avatarUrl, - Boolean enabled, - @Valid NotificationSettingsRequest notifications, - @Valid EmbedTemplatesRequest embedTemplates -) { - public WebhookSettings toSettings() { - return WebhookSettings.builder() - .discordWebhookUrl(discordWebhookUrl) - .discordAdminRoleId(discordAdminRoleId) - .botName(botName) - .avatarUrl(avatarUrl) - .enabled(Boolean.TRUE.equals(enabled)) - .notifications(notifications != null ? notifications.toSettings() : null) - .embedTemplates(embedTemplates != null ? embedTemplates.toSettings() : null) - .build(); - } - - public record NotificationSettingsRequest( - Boolean newTickets, - Boolean newPunishments, - Boolean auditLogs - ) { - public WebhookSettings.NotificationSettings toSettings() { - return WebhookSettings.NotificationSettings.builder() - .newTickets(Boolean.TRUE.equals(newTickets)) - .newPunishments(Boolean.TRUE.equals(newPunishments)) - .auditLogs(Boolean.TRUE.equals(auditLogs)) - .build(); - } - } - - public record EmbedTemplatesRequest( - @Valid EmbedTemplateRequest newTickets, - @Valid EmbedTemplateRequest newPunishments, - @Valid EmbedTemplateRequest auditLogs - ) { - public WebhookSettings.EmbedTemplates toSettings() { - return WebhookSettings.EmbedTemplates.builder() - .newTickets(newTickets != null ? newTickets.toSettings() : null) - .newPunishments(newPunishments != null ? newPunishments.toSettings() : null) - .auditLogs(auditLogs != null ? auditLogs.toSettings() : null) - .build(); - } - } - - public record EmbedTemplateRequest( - @Size(max = RequestValidationLimits.EMBED_TITLE_MAX_LENGTH) - String title, - @Size(max = RequestValidationLimits.EMBED_DESCRIPTION_MAX_LENGTH) - String description, - @Size(max = RequestValidationLimits.EMBED_COLOR_MAX_LENGTH) - String color, - @Size(max = RequestValidationLimits.EMBED_FIELDS_MAX_ENTRIES) - List<@Valid EmbedFieldRequest> fields - ) { - public WebhookSettings.EmbedTemplate toSettings() { - return WebhookSettings.EmbedTemplate.builder() - .title(title) - .description(description) - .color(color) - .fields(fields != null ? fields.stream().map(EmbedFieldRequest::toSettings).toList() : null) - .build(); - } - } - - public record EmbedFieldRequest( - @NotBlank - @Size(max = RequestValidationLimits.EMBED_FIELD_NAME_MAX_LENGTH) - String name, - @NotBlank - @Size(max = RequestValidationLimits.EMBED_FIELD_VALUE_MAX_LENGTH) - String value, - Boolean inline - ) { - public WebhookSettings.EmbedField toSettings() { - return WebhookSettings.EmbedField.builder() - .name(name) - .value(value) - .inline(Boolean.TRUE.equals(inline)) - .build(); - } - } -} diff --git a/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java b/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java index a47cbae..0a24d08 100644 --- a/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/AIModerationSettingsService.java @@ -5,20 +5,23 @@ import gg.modl.backend.settings.data.AIModerationSettings; import java.util.HashMap; import java.util.Map; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service -@Slf4j -@RequiredArgsConstructor public class AIModerationSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; private static final String SETTINGS_TYPE_AI_MODERATION = "aiModerationSettings"; + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public AIModerationSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_AI_MODERATION, this::mapToAIModerationSettings); + } + public AIModerationSettings updateAIModerationSettings(Server server, AIModerationSettings newSettings) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_AI_MODERATION); + long expectedVersion = support.currentVersion(server); AIModerationSettings toPersist = AIModerationSettings.builder() .enableAIReview(newSettings.isEnableAIReview()) @@ -28,13 +31,15 @@ public AIModerationSettings updateAIModerationSettings(Server server, AIModerati : new HashMap<>()) .build(); - SettingsDocumentService.RawSettingsState saved = - settingsDocumentService.saveRawState(server, SETTINGS_TYPE_AI_MODERATION, current.version(), codec().encode(toPersist)); - return codec().decode(saved.data()); + return support.save(server, expectedVersion, codec().encode(toPersist)).data(); } public AIModerationSettings getAIModerationSettings(Server server) { - return codec().decode(settingsDocumentService.getRawState(server, SETTINGS_TYPE_AI_MODERATION).data()); + return support.get(server); + } + + private AIModerationSettings mapToAIModerationSettings(Map data) { + return codec().decode(data); } private SettingsCodec codec() { diff --git a/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java b/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java index 1e377df..f4c908e 100644 --- a/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/ApiKeySettingsService.java @@ -1,15 +1,14 @@ package gg.modl.backend.settings.service; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerCredentialRepository; +import gg.modl.backend.database.mongo.repository.ServerLookupRepository; import gg.modl.backend.infrastructure.scheduling.SchedulerLeaseService; import gg.modl.backend.server.data.Server; -import gg.modl.backend.settings.data.Settings; import gg.modl.backend.infrastructure.util.IdGenerator; import java.time.Duration; -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.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -19,17 +18,33 @@ @Service @Slf4j -@RequiredArgsConstructor public class ApiKeySettingsService { - private final SettingsRepositoryAccess settingsRepositoryAccess; - private final IdGenerator idGenerator; - private final ServerMongoRepository serverRepository; - private final SchedulerLeaseService schedulerLeaseService; private static final String SETTINGS_TYPE_API_KEYS = "apiKeys"; private static final String API_KEY_FIELD = "api_key"; private static final String API_KEY_BACKFILL_LEASE = "server-api-key-backfill"; private static final Duration API_KEY_BACKFILL_LEASE_TTL = Duration.ofMinutes(30); + private final IdGenerator idGenerator; + private final ServerLookupRepository serverLookupRepository; + private final ServerCredentialRepository serverCredentialRepository; + private final SchedulerLeaseService schedulerLeaseService; + private final VersionedSettingsSupport> support; + + public ApiKeySettingsService( + SettingsDocumentService settingsDocumentService, + IdGenerator idGenerator, + ServerLookupRepository serverLookupRepository, + ServerCredentialRepository serverCredentialRepository, + SchedulerLeaseService schedulerLeaseService + ) { + this.idGenerator = idGenerator; + this.serverLookupRepository = serverLookupRepository; + this.serverCredentialRepository = serverCredentialRepository; + this.schedulerLeaseService = schedulerLeaseService; + this.support = VersionedSettingsSupport.>of( + settingsDocumentService, SETTINGS_TYPE_API_KEYS, LinkedHashMap::new); + } + @EventListener(ApplicationReadyEvent.class) public void backfillServerApiKeys() { if (!schedulerLeaseService.tryAcquire(API_KEY_BACKFILL_LEASE, API_KEY_BACKFILL_LEASE_TTL)) { @@ -37,7 +52,7 @@ public void backfillServerApiKeys() { } List servers; try { - servers = serverRepository.findAll(); + servers = serverLookupRepository.findAll(); } catch (Exception e) { log.error("Failed to load servers for API key backfill", e); return; @@ -69,44 +84,34 @@ private boolean syncServerApiKeyFromSettings(@NotNull Server server) { if (currentKey != null && currentKey.equals(settingsKey)) { return false; } - serverRepository.updateApiKey(server.getId(), settingsKey); + serverCredentialRepository.updateApiKey(server.getId(), settingsKey); return true; } @Nullable public Server findServerByApiKey(@NotNull String apiKey) { - return serverRepository.findByApiKey(apiKey).orElse(null); + return serverLookupRepository.findByApiKey(apiKey).orElse(null); } @Nullable public String getApiKeyFromSettings(@NotNull Server server) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - if (settings == null || settings.getData() == null) { - return null; - } - - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - Object apiKey = data.get(API_KEY_FIELD); - return apiKey instanceof String ? (String) apiKey : null; + Object apiKey = support.get(server).get(API_KEY_FIELD); + return apiKey instanceof String value ? value : null; } public void syncApiKeyToServer(@NotNull Server server, @NotNull String apiKey) { - serverRepository.updateApiKey(server.getId(), apiKey); + serverCredentialRepository.updateApiKey(server.getId(), apiKey); } public String generateApiKey(Server server, String keyType) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - @SuppressWarnings("unchecked") - Map data = settings != null && settings.getData() != null - ? new HashMap<>((Map) settings.getData()) - : new HashMap<>(); + VersionedSettings> state = support.state(server); + Map data = state.data(); String newApiKey = generateSecureApiKey(); String fieldName = getFieldNameForType(keyType); data.put(fieldName, newApiKey); - settingsRepositoryAccess.upsertSettings(server, SETTINGS_TYPE_API_KEYS, data); + support.save(server, state.version(), data); if (API_KEY_FIELD.equals(fieldName)) { syncApiKeyToServer(server, newApiKey); server.setApiKey(newApiKey); @@ -128,14 +133,8 @@ private String getFieldNameForType(String keyType) { } public boolean deleteApiKey(Server server, String keyType) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - - if (settings == null || settings.getData() == null) { - return false; - } - - @SuppressWarnings("unchecked") - Map data = new HashMap<>((Map) settings.getData()); + VersionedSettings> state = support.state(server); + Map data = state.data(); String fieldName = getFieldNameForType(keyType); if (!data.containsKey(fieldName)) { @@ -143,9 +142,9 @@ public boolean deleteApiKey(Server server, String keyType) { } data.remove(fieldName); - settingsRepositoryAccess.upsertSettings(server, SETTINGS_TYPE_API_KEYS, data); + support.save(server, state.version(), data); if (API_KEY_FIELD.equals(fieldName)) { - serverRepository.clearApiKey(server.getId()); + serverCredentialRepository.clearApiKey(server.getId()); server.setApiKey(null); } @@ -158,16 +157,8 @@ public boolean hasApiKey(Server server, String keyType) { } public String revealApiKey(Server server, String keyType) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_API_KEYS).orElse(null); - - if (settings == null || settings.getData() == null) { - return null; - } - String fieldName = getFieldNameForType(keyType); - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); - Object apiKey = data.get(fieldName); - return apiKey instanceof String ? (String) apiKey : null; + Object apiKey = support.get(server).get(fieldName); + return apiKey instanceof String value ? value : null; } } diff --git a/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java b/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java index d8c77fd..f04060d 100644 --- a/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/DomainSettingsService.java @@ -1,34 +1,33 @@ package gg.modl.backend.settings.service; import gg.modl.backend.infrastructure.cors.DynamicCorsConfigurationSource; -import gg.modl.backend.database.mongo.repository.ServerMongoRepository; +import gg.modl.backend.database.mongo.repository.ServerCustomDomainRepository; import gg.modl.backend.cloudflare.external.CloudflareClient; import gg.modl.backend.infrastructure.exception.ConflictException; import gg.modl.backend.infrastructure.exception.ResourceNotFoundException; import gg.modl.backend.infrastructure.exception.ValidationException; +import gg.modl.backend.server.data.CustomDomainStatus; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.DomainSettings; -import gg.modl.backend.settings.data.Settings; import java.net.IDN; import java.time.Instant; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service @Slf4j -@RequiredArgsConstructor public class DomainSettingsService { - private final SettingsRepositoryAccess settingsRepositoryAccess; - private final ServerMongoRepository serverRepository; + private final ServerCustomDomainRepository serverCustomDomainRepository; private final CloudflareClient cloudflareClient; private final DynamicCorsConfigurationSource corsConfigurationSource; private final CustomDomainAccessService customDomainAccessService; + private final VersionedSettingsSupport> support; private static final String SETTINGS_TYPE_DOMAIN = "domain"; private static final Pattern HOSTNAME_PATTERN = Pattern.compile("^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(?>of( + settingsDocumentService, SETTINGS_TYPE_DOMAIN, LinkedHashMap::new); + } + public DomainSettings getDomainSettings(Server server, String requestHost) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); + Map data = support.get(server); String modlSubdomainUrl = "https://" + server.getCustomDomain() + ".modl.gg"; boolean canManageCustomDomain = customDomainAccessService.canManageCustomDomain(server); - if (settings == null || settings.getData() == null) { - return DomainSettings.builder() - .customDomain(null) - .status(null) - .accessingFromCustomDomain(false) - .modlSubdomainUrl(modlSubdomainUrl) - .canManageCustomDomain(canManageCustomDomain) - .build(); - } - - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); String customDomain = getStringValue(data, "customDomain"); - boolean accessingFromCustomDomain = customDomain != null && !customDomain.isEmpty() && requestHost != null && requestHost.equalsIgnoreCase(customDomain); @@ -86,24 +87,26 @@ public DomainSettings getDomainSettings(Server server, String requestHost) { private String getStringValue(Map data, String key) { Object value = data.get(key); - return value instanceof String ? (String) value : null; + return value instanceof String string ? string : null; } private boolean getBooleanValue(Map data, String key) { Object value = data.get(key); - return value instanceof Boolean ? (Boolean) value : false; + return value instanceof Boolean bool ? bool : false; } public DomainSettings configureDomain(Server server, String customDomain) { customDomain = normalizeAndValidateCustomDomain(customDomain); - String currentDomain = extractCurrentDomain(server); + VersionedSettings> state = support.state(server); + Map current = state.data(); + String currentDomain = extractCurrentDomain(server, current); if (currentDomain != null && currentDomain.equalsIgnoreCase(customDomain)) { throw new ConflictException("This domain is already configured. Please verify the existing configuration or remove it first."); } - String currentCloudflareHostnameId = extractCurrentCloudflareHostnameId(server); + String currentCloudflareHostnameId = extractCurrentCloudflareHostnameId(server, current); CloudflareClient.CustomHostnameResult existingHostname = cloudflareClient.findCustomHostnameByName(customDomain); if (existingHostname != null) { if (currentCloudflareHostnameId == null || !existingHostname.id().equals(currentCloudflareHostnameId)) { @@ -146,7 +149,7 @@ public DomainSettings configureDomain(Server server, String customDomain) { data.put("status", buildDomainStatusMap(status)); data.put("cloudflareHostnameId", cloudflareHostnameId); - settingsRepositoryAccess.upsertSettings(server, SETTINGS_TYPE_DOMAIN, data); + support.save(server, state.version(), data); updateServerDocument(server.getId(), customDomain, initialStatus, cloudflareHostnameId, error); @@ -155,33 +158,32 @@ public DomainSettings configureDomain(Server server, String customDomain) { private void updateServerDocument(String serverId, String customDomain, String status, String cloudflareHostnameId, String error) { - serverRepository.updateCustomDomain(serverId, customDomain, status, cloudflareHostnameId, error); + serverCustomDomainRepository.updateCustomDomain(serverId, customDomain, mapDomainStatus(status), cloudflareHostnameId, error); corsConfigurationSource.invalidateCache(customDomain); log.debug("Invalidated CORS cache for domain: {}", customDomain); } - private String extractCurrentDomain(Server server) { + private CustomDomainStatus mapDomainStatus(String status) { + return switch (status) { + case "active" -> CustomDomainStatus.ACTIVE; + case "error" -> CustomDomainStatus.ERROR; + case "verifying" -> CustomDomainStatus.VERIFYING; + default -> CustomDomainStatus.PENDING; + }; + } + + private String extractCurrentDomain(Server server, Map current) { String currentDomain = server.getCustomDomainOverride(); if (currentDomain == null) { - Settings existingSettings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - if (existingSettings != null && existingSettings.getData() != null) { - @SuppressWarnings("unchecked") - Map data = (Map) existingSettings.getData(); - currentDomain = getStringValue(data, "customDomain"); - } + currentDomain = getStringValue(current, "customDomain"); } return currentDomain; } - private String extractCurrentCloudflareHostnameId(Server server) { + private String extractCurrentCloudflareHostnameId(Server server, Map current) { String currentCloudflareHostnameId = server.getCustomDomainCloudflareId(); if (currentCloudflareHostnameId == null) { - Settings existingSettings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); - if (existingSettings != null && existingSettings.getData() != null) { - @SuppressWarnings("unchecked") - Map data = (Map) existingSettings.getData(); - currentCloudflareHostnameId = getStringValue(data, "cloudflareHostnameId"); - } + currentCloudflareHostnameId = getStringValue(current, "cloudflareHostnameId"); } return currentCloudflareHostnameId; } @@ -223,14 +225,13 @@ private String mapCloudflareStatus(String cfStatus) { public DomainSettings verifyDomain(Server server, String domain) { domain = normalizeAndValidateCustomDomain(domain); - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); + VersionedSettings> state = support.state(server); + Map data = state.data(); - if (settings == null || settings.getData() == null) { + if (data.isEmpty()) { throw new ResourceNotFoundException("No domain configured"); } - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); String configuredDomain = getStringValue(data, "customDomain"); String cloudflareHostnameId = getStringValue(data, "cloudflareHostnameId"); @@ -286,7 +287,7 @@ public DomainSettings verifyDomain(Server server, String domain) { data.put("cloudflareHostnameId", cloudflareHostnameId); } - settingsRepositoryAccess.updateDataSettings(server, SETTINGS_TYPE_DOMAIN, data); + support.save(server, state.version(), data); updateServerDocument(server.getId(), domain, verifiedStatus, cloudflareHostnameId, error); @@ -326,13 +327,11 @@ private boolean isReservedDomain(String domain) { } public void removeDomain(Server server) { - Settings settings = settingsRepositoryAccess.findSettings(server, SETTINGS_TYPE_DOMAIN).orElse(null); + Map data = support.get(server); String customDomain = null; - if (settings != null && settings.getData() != null) { - @SuppressWarnings("unchecked") - Map data = (Map) settings.getData(); + if (!data.isEmpty()) { String cloudflareHostnameId = getStringValue(data, "cloudflareHostnameId"); customDomain = getStringValue(data, "customDomain"); @@ -348,7 +347,7 @@ public void removeDomain(Server server) { customDomain = server.getCustomDomainOverride(); } - settingsRepositoryAccess.removeSettings(server, SETTINGS_TYPE_DOMAIN); + support.delete(server); clearServerDomainFields(server.getId()); @@ -359,6 +358,6 @@ public void removeDomain(Server server) { } private void clearServerDomainFields(String serverId) { - serverRepository.clearCustomDomain(serverId); + serverCustomDomainRepository.clearCustomDomain(serverId); } } 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 83d8bad..695c815 100644 --- a/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/GeneralSettingsService.java @@ -5,30 +5,30 @@ 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; + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public GeneralSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_GENERAL, this::mapToGeneralSettings); + } + public GeneralSettings getGeneralSettings(Server server) { - return getGeneralSettingsState(server).data(); + return support.get(server); } 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()); + return support.state(server); } private GeneralSettings mapToGeneralSettings(Map data) { @@ -82,8 +82,7 @@ public VersionedSettings patchGeneralSettings( long expectedVersion, GeneralSettings patch ) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_GENERAL); - Map data = new LinkedHashMap<>(current.data()); + Map data = support.currentData(server); putIfNotNull(data, "serverDisplayName", patch.getServerDisplayName(), MAX_SERVER_NAME_LENGTH); putIfNotNull(data, "discordWebhookUrl", patch.getDiscordWebhookUrl(), MAX_URL_LENGTH); @@ -91,13 +90,7 @@ public VersionedSettings patchGeneralSettings( 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()); + return support.save(server, expectedVersion, data); } private void putIfNotNull(Map data, String key, String value, int maxLength) { 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 a358644..9df77a7 100644 --- a/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/OffenderThresholdSettingsService.java @@ -8,35 +8,36 @@ 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 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; + + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; 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 OffenderThresholdSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_STATUS_THRESHOLDS, this::mapToThresholdSettings); + } public OffenderThresholdSettings getThresholdSettings(Server server) { - return thresholdCache.get(server.getId(), id -> getThresholdSettingsState(server).data()); + return thresholdCache.get(server.getId(), id -> support.get(server)); } 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()); + return support.state(server); } public VersionedSettings patchThresholdSettings( @@ -44,7 +45,7 @@ public VersionedSettings patchThresholdSettings( long expectedVersion, OffenderThresholdSettings patch ) { - OffenderThresholdSettings current = getThresholdSettingsState(server).data(); + OffenderThresholdSettings current = support.state(server).data(); if (patch != null) { if (patch.getSocial() != null) { current.setSocial(sanitizeCategoryThresholds(patch.getSocial())); @@ -57,23 +58,11 @@ public VersionedSettings patchThresholdSettings( current = normalizeSettings(current); Map data = codec().encode(current); - SettingsDocumentService.RawSettingsState updated; try { - updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_STATUS_THRESHOLDS, - expectedVersion, - new LinkedHashMap<>(data) - ); + return support.save(server, 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) { diff --git a/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java b/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java index 99cd112..d4a005c 100644 --- a/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java +++ b/src/main/java/gg/modl/backend/settings/service/PunishmentTypeService.java @@ -7,6 +7,7 @@ import gg.modl.backend.server.data.Server; import gg.modl.backend.server.service.ServerTimestampService; import gg.modl.backend.settings.data.DefaultPunishmentTypes; +import gg.modl.backend.settings.data.PunishmentCategory; import gg.modl.backend.settings.data.PunishmentType; import gg.modl.backend.settings.data.Settings; import java.time.Duration; @@ -14,13 +15,11 @@ import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import java.util.Objects; @Service -@Slf4j @RequiredArgsConstructor public class PunishmentTypeService { private final SettingsRepositoryAccess settingsRepositoryAccess; @@ -33,12 +32,6 @@ public class PunishmentTypeService { .maximumSize(500) .build(); - public Optional getPunishmentTypeById(@NotNull Server server, int id) { - return getPunishmentTypes(server).stream() - .filter(pt -> pt.getId() != null && pt.getId() == id) - .findFirst(); - } - public List getPunishmentTypes(@NotNull Server server) { return typesCache.get(server.getId(), id -> loadOrInitializeTypes(server)); } @@ -76,7 +69,7 @@ private List persistPunishmentTypes(@NotNull Server server, @Not } public PunishmentType updatePunishmentType(@NotNull Server server, int ordinal, @NotNull PunishmentType updatedType) { - if (ordinal == 0 || ordinal == 5) { + if (ordinal == PunishmentCategory.MIN_CORE_ORDINAL || ordinal == PunishmentCategory.MAX_CORE_ORDINAL) { throw new IllegalArgumentException("Kick and Blacklist punishment types cannot be configured"); } @@ -121,7 +114,7 @@ public PunishmentType updatePunishmentType(@NotNull Server server, int ordinal, } public boolean deletePunishmentType(@NotNull Server server, int ordinal) { - if (ordinal < 6) { + if (ordinal <= PunishmentCategory.MAX_CORE_ORDINAL) { throw new IllegalArgumentException("Cannot delete core administrative punishment types"); } diff --git a/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java b/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java index cea0539..75bc1ba 100644 --- a/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/QuickResponseSettingsService.java @@ -7,26 +7,27 @@ import java.util.ArrayList; 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 QuickResponseSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; private static final String SETTINGS_TYPE_QUICK_RESPONSES = "quickResponses"; + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public QuickResponseSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_QUICK_RESPONSES, this::mapToQuickResponseSettings); + } + public QuickResponseSettings getQuickResponseSettings(Server server) { - return getQuickResponseSettingsState(server).data(); + return support.get(server); } public VersionedSettings getQuickResponseSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_QUICK_RESPONSES); - QuickResponseSettings settings = mapToQuickResponseSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + return support.state(server); } public QuickResponseSettings.Action findAction(QuickResponseSettings settings, String categoryId, String actionId) { @@ -55,24 +56,13 @@ public VersionedSettings patchQuickResponseSettings( long expectedVersion, UpdateQuickResponsesRequest quickResponses ) { - QuickResponseSettings currentSettings = getQuickResponseSettings(server); + QuickResponseSettings currentSettings = support.get(server); if (quickResponses != null && quickResponses.categories() != null) { currentSettings.setCategories(quickResponses.categories()); } Map data = codec().encode(currentSettings); - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_QUICK_RESPONSES, - expectedVersion, - new LinkedHashMap<>(data) - ); - return new VersionedSettings<>(mapToQuickResponseSettings(updated.data()), updated.version(), updated.updatedAt()); - } - - public void updateQuickResponseSettings(Server server, UpdateQuickResponsesRequest quickResponses) { - long expectedVersion = getQuickResponseSettingsState(server).version(); - patchQuickResponseSettings(server, expectedVersion, quickResponses); + return support.save(server, expectedVersion, new LinkedHashMap<>(data)); } private QuickResponseSettings mapToQuickResponseSettings(Map data) { diff --git a/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java b/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java index 50c7c66..8647d55 100644 --- a/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/ReplayRetentionSettingsService.java @@ -6,28 +6,29 @@ import gg.modl.backend.settings.data.ReplayRetentionSettings; 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 ReplayRetentionSettingsService { private static final String SETTINGS_TYPE_REPLAY_RETENTION = "replayRetention"; private static final int MIN_DAYS = 1; private static final int MAX_DAYS = 365; - private final SettingsDocumentService settingsDocumentService; private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public ReplayRetentionSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_REPLAY_RETENTION, this::mapToSettings); + } public ReplayRetentionSettings getReplayRetentionSettings(Server server) { - return getReplayRetentionSettingsState(server).data(); + return support.get(server); } public VersionedSettings getReplayRetentionSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_REPLAY_RETENTION); - return new VersionedSettings<>(mapToSettings(state.data()), state.version(), state.updatedAt()); + return support.state(server); } public VersionedSettings patchReplayRetentionSettings( @@ -36,8 +37,7 @@ public VersionedSettings patchReplayRetentionSettings( Boolean enabled, Integer days ) { - SettingsDocumentService.RawSettingsState current = settingsDocumentService.getRawState(server, SETTINGS_TYPE_REPLAY_RETENTION); - Map data = new LinkedHashMap<>(current.data()); + Map data = support.currentData(server); ReplayRetentionSettings merged = mapToSettings(data); if (enabled != null) { @@ -51,13 +51,7 @@ public VersionedSettings patchReplayRetentionSettings( data.put("enabled", normalized.isEnabled()); data.put("days", normalized.getDays()); - SettingsDocumentService.RawSettingsState updated = settingsDocumentService.saveRawState( - server, - SETTINGS_TYPE_REPLAY_RETENTION, - expectedVersion, - data - ); - return new VersionedSettings<>(mapToSettings(updated.data()), updated.version(), updated.updatedAt()); + return support.save(server, expectedVersion, data); } private ReplayRetentionSettings mapToSettings(Map data) { 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 46f51d1..207334c 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsDocumentService.java @@ -60,6 +60,10 @@ public RawSettingsState getRawState(Server server, String type) { return toRawState(settings); } + public void deleteState(Server server, String type) { + settingsRepository.removeByType(server, type); + } + private void throwConflict(long currentVersion) { throw new SettingsConflictException( "Settings were modified by another user. Reload and retry.", diff --git a/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java b/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java index 9b9197e..5f310af 100644 --- a/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java +++ b/src/main/java/gg/modl/backend/settings/service/SettingsRepositoryAccess.java @@ -4,7 +4,6 @@ import gg.modl.backend.infrastructure.util.MongoKeyUtils; import gg.modl.backend.server.data.Server; import gg.modl.backend.settings.data.Settings; -import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -18,23 +17,7 @@ public Optional findSettings(Server server, String type) { return settingsRepository.findByType(server, type); } - public void upsertSettings(Server server, String type, Map data) { - settingsRepository.upsertData(server, type, MongoKeyUtils.sanitizeKeys(data)); - } - public void upsertListSettings(Server server, String type, Object data) { settingsRepository.upsertListData(server, type, MongoKeyUtils.sanitizeValue(data)); } - - public void updateDataSettings(Server server, String type, Map data) { - settingsRepository.updateDataByType(server, type, MongoKeyUtils.sanitizeKeys(data)); - } - - public void removeSettings(Server server, String type) { - settingsRepository.removeByType(server, type); - } - - public void saveEntity(Server server, Settings settings) { - settingsRepository.saveEntity(server, settings); - } } 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 bc74370..ffd1b44 100644 --- a/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/TicketFormSettingsService.java @@ -9,18 +9,21 @@ 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"; + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public TicketFormSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_TICKET_FORMS, this::mapToTicketFormSettings); + } + public VersionedSettings patchTicketFormSettings( Server server, long expectedVersion, @@ -30,19 +33,7 @@ public VersionedSettings patchTicketFormSettings( 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(); + return support.save(server, expectedVersion, new LinkedHashMap<>(data)); } public TicketFormSettings.TicketForm getFormByType(Server server, String formType) { @@ -59,13 +50,11 @@ public TicketFormSettings.TicketForm getFormByType(Server server, String formTyp } public TicketFormSettings getTicketFormSettings(Server server) { - return getTicketFormSettingsState(server).data(); + return support.get(server); } 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()); + return support.state(server); } private TicketFormSettings mapToTicketFormSettings(Map data) { @@ -133,23 +122,6 @@ private TicketFormSettings.TicketForm emptyForm() { .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()); diff --git a/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java b/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java index 13eb2cd..0087cab 100644 --- a/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java +++ b/src/main/java/gg/modl/backend/settings/service/TicketLabelSettingsService.java @@ -14,16 +14,12 @@ import java.util.UUID; import java.util.regex.Pattern; import java.util.stream.Collectors; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Service -@RequiredArgsConstructor @Slf4j public class TicketLabelSettingsService { - private final SettingsDocumentService settingsDocumentService; - private final ObjectMapper objectMapper; private static final String SETTINGS_TYPE_TICKET_LABELS = "ticketLabels"; private static final int MAX_LABELS = 100; private static final int MAX_LABEL_ID_LENGTH = 64; @@ -31,14 +27,21 @@ public class TicketLabelSettingsService { private static final int MAX_LABEL_DESCRIPTION_LENGTH = 512; private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"); + private final ObjectMapper objectMapper; + private final VersionedSettingsSupport support; + + public TicketLabelSettingsService(SettingsDocumentService settingsDocumentService, ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + this.support = VersionedSettingsSupport.of( + settingsDocumentService, SETTINGS_TYPE_TICKET_LABELS, this::mapToTicketLabelSettings); + } + public TicketLabelSettings getTicketLabelSettings(Server server) { - return getTicketLabelSettingsState(server).data(); + return support.get(server); } public VersionedSettings getTicketLabelSettingsState(Server server) { - SettingsDocumentService.RawSettingsState state = settingsDocumentService.getRawState(server, SETTINGS_TYPE_TICKET_LABELS); - TicketLabelSettings settings = mapToTicketLabelSettings(state.data()); - return new VersionedSettings<>(settings, state.version(), state.updatedAt()); + return support.state(server); } public VersionedSettings patchTicketLabelSettings( @@ -46,8 +49,7 @@ public VersionedSettings patchTicketLabelSettings( long expectedVersion, List