From 2572e24e8399eee151e0d4a561bcd52ff7133d05 Mon Sep 17 00:00:00 2001 From: Natalia Portillo Date: Thu, 4 Jun 2026 15:41:26 +0100 Subject: [PATCH] Refactor error handling in services and controllers to use ExtractDetail method for consistent error messages - Updated SoundSynthVideosController to return ProblemDetails for bad requests and conflicts. - Modified UsersController to return ProblemDetails for user not found and account purge failures. - Enhanced BooksService, DocumentsService, GpusService, InstructionSetExtensionsService, MachineFamiliesService, MagazinesService, ResolutionsService, ScreensService, SoftwareFamiliesService, SoftwarePlatformsService, SoftwareVersionsService, SoundSynthsService, and WwpcImportsService to utilize ExtractDetail for error messages from ApiException. - Introduced ExtractDetail method to standardize error message extraction from ApiException across multiple services. --- Marechai.Server/Controllers/AuthController.cs | 42 ++++++------- .../Controllers/BooksController.cs | 24 ++++---- .../Controllers/CompaniesController.cs | 18 +++--- .../Controllers/GpuPhotosController.cs | 53 ++++++++-------- .../Controllers/GpuVideosController.cs | 4 +- .../Controllers/MachinePhotosController.cs | 53 ++++++++-------- .../Controllers/MachineVideosController.cs | 4 +- .../Controllers/MagazineIssuesController.cs | 24 ++++---- .../Controllers/PeopleController.cs | 24 ++++---- .../Controllers/ProcessorPhotosController.cs | 53 ++++++++-------- .../Controllers/ProcessorVideosController.cs | 4 +- .../Controllers/SoftwareController.cs | 42 ++++++------- .../Controllers/SoftwareCoversController.cs | 59 +++++++++--------- .../Controllers/SoftwarePromoArtController.cs | 33 +++++----- .../Controllers/SoftwareReleasesController.cs | 32 +++++----- .../SoftwareScreenshotsController.cs | 61 ++++++++++--------- .../Controllers/SoftwareVideosController.cs | 4 +- .../Controllers/SoundSynthPhotosController.cs | 55 +++++++++-------- .../Controllers/SoundSynthVideosController.cs | 4 +- .../Controllers/UsersController.cs | 16 ++--- Marechai/Services/BooksService.cs | 45 +++++++++----- Marechai/Services/DocumentsService.cs | 43 +++++++++---- Marechai/Services/GpusService.cs | 14 ++--- .../InstructionSetExtensionsService.cs | 23 ++++++- Marechai/Services/MachineFamiliesService.cs | 23 ++++++- Marechai/Services/MagazinesService.cs | 55 +++++++++++------ Marechai/Services/OldDosImportsService.cs | 26 ++++++-- Marechai/Services/ResolutionsService.cs | 23 ++++++- Marechai/Services/ScreensService.cs | 27 ++++++-- Marechai/Services/SoftwareFamiliesService.cs | 27 ++++++-- Marechai/Services/SoftwarePlatformsService.cs | 23 ++++++- Marechai/Services/SoftwareVersionsService.cs | 27 ++++++-- Marechai/Services/SoundSynthsService.cs | 10 +-- Marechai/Services/WwpcImportsService.cs | 26 ++++++-- 34 files changed, 607 insertions(+), 394 deletions(-) diff --git a/Marechai.Server/Controllers/AuthController.cs b/Marechai.Server/Controllers/AuthController.cs index 6daddcd3..639c96bc 100644 --- a/Marechai.Server/Controllers/AuthController.cs +++ b/Marechai.Server/Controllers/AuthController.cs @@ -349,7 +349,7 @@ public class AuthController ApplicationUser user = await userManager.FindByEmailAsync(request.Email); // Generic message: do NOT differentiate between unknown user and bad/expired token. - if(user is null) return BadRequest("Invalid token or email."); + if(user is null) return Problem(detail: "Invalid token or email.", statusCode: StatusCodes.Status400BadRequest); IdentityResult result = await userManager.ResetPasswordAsync(user, request.Token, request.NewPassword); @@ -932,19 +932,19 @@ public class AuthController if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); ApplicationUser user = await userManager.FindByIdAsync(userId); @@ -1256,7 +1256,7 @@ public class AuthController _ => null }; - if(identityProvider is null) return BadRequest("Unknown two-factor provider."); + if(identityProvider is null) return Problem(detail: "Unknown two-factor provider.", statusCode: StatusCodes.Status400BadRequest); bool ok = await userManager.VerifyTwoFactorTokenAsync(user, identityProvider, request.Code); @@ -1337,7 +1337,7 @@ public class AuthController ApplicationUser user = await userManager.FindByIdAsync(userId); if(user is null) return Unauthorized(); - if(!user.TwoFactorViaEmail) return BadRequest("Email two-factor is not enabled for this account."); + if(!user.TwoFactorViaEmail) return Problem(detail: "Email two-factor is not enabled for this account.", statusCode: StatusCodes.Status400BadRequest); string code = await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); await SendLocalizedLoginCodeAsync(user.Email!, code); @@ -1419,7 +1419,7 @@ public class AuthController bool ok = await userManager.VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultAuthenticatorProvider, request.Code); - if(!ok) return BadRequest("Invalid verification code."); + if(!ok) return Problem(detail: "Invalid verification code.", statusCode: StatusCodes.Status400BadRequest); bool wasFirstMethod = !user.TwoFactorEnabled; @@ -1454,7 +1454,7 @@ public class AuthController ApplicationUser user = await userManager.FindByIdAsync(userId); if(user is null) return Unauthorized(); - if(!user.EmailConfirmed) return BadRequest("Email is not confirmed."); + if(!user.EmailConfirmed) return Problem(detail: "Email is not confirmed.", statusCode: StatusCodes.Status400BadRequest); string code = await userManager.GenerateTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider); await SendLocalizedLoginCodeAsync(user.Email!, code); @@ -1480,13 +1480,13 @@ public class AuthController ApplicationUser user = await userManager.FindByIdAsync(userId); if(user is null) return Unauthorized(); - if(!user.EmailConfirmed) return BadRequest("Email is not confirmed."); + if(!user.EmailConfirmed) return Problem(detail: "Email is not confirmed.", statusCode: StatusCodes.Status400BadRequest); if(!await userManager.CheckPasswordAsync(user, request.Password)) - return BadRequest("Invalid password."); + return Problem(detail: "Invalid password.", statusCode: StatusCodes.Status400BadRequest); bool ok = await userManager.VerifyTwoFactorTokenAsync(user, TokenOptions.DefaultEmailProvider, request.Code); - if(!ok) return BadRequest("Invalid verification code."); + if(!ok) return Problem(detail: "Invalid verification code.", statusCode: StatusCodes.Status400BadRequest); bool wasFirstMethod = !user.TwoFactorEnabled; @@ -1522,13 +1522,13 @@ public class AuthController ApplicationUser user = await userManager.FindByIdAsync(userId); if(user is null) return Unauthorized(); - if(!user.TwoFactorViaAuthenticator) return BadRequest("Authenticator is not enabled."); + if(!user.TwoFactorViaAuthenticator) return Problem(detail: "Authenticator is not enabled.", statusCode: StatusCodes.Status400BadRequest); if(!await userManager.CheckPasswordAsync(user, request.Password)) - return BadRequest("Invalid password."); + return Problem(detail: "Invalid password.", statusCode: StatusCodes.Status400BadRequest); if(!await VerifyOwnTwoFactorCodeAsync(user, request.Provider, request.Code)) - return BadRequest("Invalid verification code."); + return Problem(detail: "Invalid verification code.", statusCode: StatusCodes.Status400BadRequest); await userManager.ResetAuthenticatorKeyAsync(user); user.TwoFactorViaAuthenticator = false; @@ -1562,13 +1562,13 @@ public class AuthController ApplicationUser user = await userManager.FindByIdAsync(userId); if(user is null) return Unauthorized(); - if(!user.TwoFactorViaEmail) return BadRequest("Email two-factor is not enabled."); + if(!user.TwoFactorViaEmail) return Problem(detail: "Email two-factor is not enabled.", statusCode: StatusCodes.Status400BadRequest); if(!await userManager.CheckPasswordAsync(user, request.Password)) - return BadRequest("Invalid password."); + return Problem(detail: "Invalid password.", statusCode: StatusCodes.Status400BadRequest); if(!await VerifyOwnTwoFactorCodeAsync(user, request.Provider, request.Code)) - return BadRequest("Invalid verification code."); + return Problem(detail: "Invalid verification code.", statusCode: StatusCodes.Status400BadRequest); user.TwoFactorViaEmail = false; @@ -1603,13 +1603,13 @@ public class AuthController ApplicationUser user = await userManager.FindByIdAsync(userId); if(user is null) return Unauthorized(); - if(!user.TwoFactorEnabled) return BadRequest("Two-factor authentication is not enabled."); + if(!user.TwoFactorEnabled) return Problem(detail: "Two-factor authentication is not enabled.", statusCode: StatusCodes.Status400BadRequest); if(!await userManager.CheckPasswordAsync(user, request.Password)) - return BadRequest("Invalid password."); + return Problem(detail: "Invalid password.", statusCode: StatusCodes.Status400BadRequest); if(!await VerifyOwnTwoFactorCodeAsync(user, request.Provider, request.Code)) - return BadRequest("Invalid verification code."); + return Problem(detail: "Invalid verification code.", statusCode: StatusCodes.Status400BadRequest); IEnumerable codes = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); await userManager.ResetAccessFailedCountAsync(user); diff --git a/Marechai.Server/Controllers/BooksController.cs b/Marechai.Server/Controllers/BooksController.cs index 253a0cc6..76f3c41f 100644 --- a/Marechai.Server/Controllers/BooksController.cs +++ b/Marechai.Server/Controllers/BooksController.cs @@ -923,19 +923,19 @@ public class BooksController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); Book book = await context.Books.FindAsync(id); @@ -1053,18 +1053,18 @@ public class BooksController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Verify the targeted book exists; we don't want stray uploads for nonexistent ids. bool bookExists = await context.Books.AsNoTracking().AnyAsync(b => b.Id == id); @@ -1106,18 +1106,18 @@ public class BooksController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Cleanup: each user gets at most ONE pending NEW-book cover at a time. Replace any // prior upload (keyed on EntityId=0) before storing the new one. diff --git a/Marechai.Server/Controllers/CompaniesController.cs b/Marechai.Server/Controllers/CompaniesController.cs index 3b266938..c912a9e8 100644 --- a/Marechai.Server/Controllers/CompaniesController.cs +++ b/Marechai.Server/Controllers/CompaniesController.cs @@ -454,15 +454,15 @@ public class CompaniesController(MarechaiContext context) : ControllerBase [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> GetMergePreviewAsync(int targetId, int sourceId) { - if(targetId == sourceId) return BadRequest("Cannot merge a company into itself."); + if(targetId == sourceId) return Problem(detail: "Cannot merge a company into itself.", statusCode: StatusCodes.Status400BadRequest); Company target = await context.Companies.FindAsync(targetId); - if(target is null) return NotFound("Target company not found."); + if(target is null) return Problem(detail: "Target company not found.", statusCode: StatusCodes.Status404NotFound); Company source = await context.Companies.FindAsync(sourceId); - if(source is null) return NotFound("Source company not found."); + if(source is null) return Problem(detail: "Source company not found.", statusCode: StatusCodes.Status404NotFound); // Logos — direct re-parent, no dedup int logosCount = await context.CompanyLogos.CountAsync(l => l.CompanyId == sourceId); @@ -654,19 +654,19 @@ public class CompaniesController(MarechaiContext context) : ControllerBase if(userId is null) return Unauthorized(); - if(request is null) return BadRequest("Merge request body is required."); + if(request is null) return Problem(detail: "Merge request body is required.", statusCode: StatusCodes.Status400BadRequest); - if(string.IsNullOrWhiteSpace(request.Name)) return BadRequest("Name is required."); + if(string.IsNullOrWhiteSpace(request.Name)) return Problem(detail: "Name is required.", statusCode: StatusCodes.Status400BadRequest); - if(targetId == sourceId) return BadRequest("Cannot merge a company into itself."); + if(targetId == sourceId) return Problem(detail: "Cannot merge a company into itself.", statusCode: StatusCodes.Status400BadRequest); Company target = await context.Companies.FindAsync(targetId); - if(target is null) return NotFound("Target company not found."); + if(target is null) return Problem(detail: "Target company not found.", statusCode: StatusCodes.Status404NotFound); Company source = await context.Companies.FindAsync(sourceId); - if(source is null) return NotFound("Source company not found."); + if(source is null) return Problem(detail: "Source company not found.", statusCode: StatusCodes.Status404NotFound); string sourceName = source.Name; @@ -947,7 +947,7 @@ public class CompaniesController(MarechaiContext context) : ControllerBase { await transaction.RollbackAsync(); - return Conflict($"Merge failed: {ex.Message}"); + return Problem(detail: $"Merge failed: {ex.Message}", statusCode: StatusCodes.Status409Conflict); } // Mark pending suggestions for the deleted source company as Stale (mirrors DeleteAsync). diff --git a/Marechai.Server/Controllers/GpuPhotosController.cs b/Marechai.Server/Controllers/GpuPhotosController.cs index bef7f7bb..f8823707 100644 --- a/Marechai.Server/Controllers/GpuPhotosController.cs +++ b/Marechai.Server/Controllers/GpuPhotosController.cs @@ -257,18 +257,18 @@ public class GpuPhotosController(MarechaiContext context, IConfiguration configu if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Read the file into memory using var ms = new MemoryStream(); @@ -454,29 +454,30 @@ public class GpuPhotosController(MarechaiContext context, IConfiguration configu if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool gpuExists = await context.Gpus.AnyAsync(g => g.Id == gpuId); - if(!gpuExists) return NotFound("GPU not found."); + if(!gpuExists) return Problem(detail: "GPU not found.", statusCode: StatusCodes.Status404NotFound); int currentCount = PendingImageStore.CountByUploaderForParentEntity(_assetRootPath, "gpus", userId, (byte)SuggestionEntityType.GpuPhoto, gpuId); if(currentCount >= PendingPhotosPerUserPerGpuCap) - return Conflict($"You already have {currentCount} pending photos for this GPU. Maximum is " + - $"{PendingPhotosPerUserPerGpuCap} per GPU. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending photos for this GPU. Maximum is " + + $"{PendingPhotosPerUserPerGpuCap} per GPU. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; @@ -607,15 +608,15 @@ public class GpuPhotosController(MarechaiContext context, IConfiguration configu string userId = User.FindFirstValue(ClaimTypes.Sid); if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_adminBatchAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF.", statusCode: StatusCodes.Status400BadRequest); bool gpuExists = await context.Gpus.AnyAsync(g => g.Id == gpuId); - if(!gpuExists) return NotFound("GPU not found."); + if(!gpuExists) return Problem(detail: "GPU not found.", statusCode: StatusCodes.Status404NotFound); // Persist FIRST (with a temporary placeholder for dimensions), then content-sniff // and either re-write the sidecar with real dimensions or reject + delete the file. @@ -632,22 +633,22 @@ public class GpuPhotosController(MarechaiContext context, IConfiguration configu if(imagePath is null) { PendingImageStore.Delete(_assetRootPath, "gpus", guid); - return BadRequest("Failed to persist uploaded file."); + return Problem(detail: "Failed to persist uploaded file.", statusCode: StatusCodes.Status400BadRequest); } (string magickFormat, int width, int height) = Photos.Identify(imagePath); if(magickFormat is null || !_adminBatchAllowedMagickFormats.Contains(magickFormat)) { PendingImageStore.Delete(_assetRootPath, "gpus", guid); - return StatusCode(StatusCodes.Status415UnsupportedMediaType, - "File content does not match a supported image format."); + return Problem(detail: "File content does not match a supported image format.", + statusCode: StatusCodes.Status415UnsupportedMediaType); } byte[] thumbBytes = Photos.GenerateThumbnailJpeg(imagePath); if(thumbBytes is null) { PendingImageStore.Delete(_assetRootPath, "gpus", guid); - return BadRequest("Failed to generate thumbnail for the uploaded image."); + return Problem(detail: "Failed to generate thumbnail for the uploaded image.", statusCode: StatusCodes.Status400BadRequest); } await PendingImageStore.StoreThumbnailAsync(_assetRootPath, "gpus", guid, thumbBytes); @@ -726,13 +727,13 @@ public class GpuPhotosController(MarechaiContext context, IConfiguration configu if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(request is null || request.Items is null || request.Items.Count == 0) - return BadRequest("No items provided."); + return Problem(detail: "No items provided.", statusCode: StatusCodes.Status400BadRequest); bool gpuExists = await context.Gpus.AnyAsync(g => g.Id == request.GpuId); - if(!gpuExists) return NotFound("GPU not found."); + if(!gpuExists) return Problem(detail: "GPU not found.", statusCode: StatusCodes.Status404NotFound); bool licenseExists = await context.Licenses.AnyAsync(l => l.Id == request.LicenseId); - if(!licenseExists) return NotFound("License not found."); + if(!licenseExists) return Problem(detail: "License not found.", statusCode: StatusCodes.Status404NotFound); // Validate every pending id belongs to the caller and matches gpu/admin scope. foreach(AdminGpuPhotoBatchCommitItemDto item in request.Items) @@ -740,15 +741,15 @@ public class GpuPhotosController(MarechaiContext context, IConfiguration configu PendingImageStore.PendingMetadata meta = await PendingImageStore.GetMetadataAsync(_assetRootPath, "gpus", item.PendingId); - if(meta is null) return NotFound($"Pending image {item.PendingId} not found."); + if(meta is null) return Problem(detail: $"Pending image {item.PendingId} not found.", statusCode: StatusCodes.Status404NotFound); if(meta.EntityType != (byte)SuggestionEntityType.GpuPhoto) - return BadRequest($"Pending image {item.PendingId} is not a GPU photo."); + return Problem(detail: $"Pending image {item.PendingId} is not a GPU photo.", statusCode: StatusCodes.Status400BadRequest); if(!meta.IsAdminStaging) - return BadRequest($"Pending image {item.PendingId} is not an admin-staged image."); + return Problem(detail: $"Pending image {item.PendingId} is not an admin-staged image.", statusCode: StatusCodes.Status400BadRequest); if(!string.Equals(meta.UploadedById, userId, StringComparison.Ordinal)) return Forbid(); if(meta.ParentEntityId != request.GpuId) - return BadRequest($"Pending image {item.PendingId} was staged for a different GPU."); + return Problem(detail: $"Pending image {item.PendingId} was staged for a different GPU.", statusCode: StatusCodes.Status400BadRequest); } Guid jobId = batchJobs.StartJob(userId, request.GpuId, request.Items.Count); diff --git a/Marechai.Server/Controllers/GpuVideosController.cs b/Marechai.Server/Controllers/GpuVideosController.cs index 560d08aa..bc109812 100644 --- a/Marechai.Server/Controllers/GpuVideosController.cs +++ b/Marechai.Server/Controllers/GpuVideosController.cs @@ -97,7 +97,7 @@ public class GpuVideosController(MarechaiContext context) : ControllerBase if(userId is null) return Unauthorized(); if(string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.VideoId)) - return BadRequest("Provider and Video ID are required."); + return Problem(detail: "Provider and Video ID are required.", statusCode: StatusCodes.Status400BadRequest); string provider = request.Provider.Trim(); string videoId = request.VideoId.Trim(); @@ -111,7 +111,7 @@ public class GpuVideosController(MarechaiContext context) : ControllerBase v.Provider == provider && v.VideoId == videoId); - if(exists) return Conflict("This video is already linked to this GPU."); + if(exists) return Problem(detail: "This video is already linked to this GPU.", statusCode: StatusCodes.Status409Conflict); var model = new GpuVideo { diff --git a/Marechai.Server/Controllers/MachinePhotosController.cs b/Marechai.Server/Controllers/MachinePhotosController.cs index 1dd091ab..6ba48090 100644 --- a/Marechai.Server/Controllers/MachinePhotosController.cs +++ b/Marechai.Server/Controllers/MachinePhotosController.cs @@ -257,18 +257,18 @@ public class MachinePhotosController(MarechaiContext context, IConfiguration con if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Read the file into memory using var ms = new MemoryStream(); @@ -456,29 +456,30 @@ public class MachinePhotosController(MarechaiContext context, IConfiguration con if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool machineExists = await context.Machines.AnyAsync(m => m.Id == machineId); - if(!machineExists) return NotFound("Machine not found."); + if(!machineExists) return Problem(detail: "Machine not found.", statusCode: StatusCodes.Status404NotFound); int currentCount = PendingImageStore.CountByUploaderForParentEntity(_assetRootPath, "machines", userId, (byte)SuggestionEntityType.MachinePhoto, machineId); if(currentCount >= PendingPhotosPerUserPerMachineCap) - return Conflict($"You already have {currentCount} pending photos for this machine. Maximum is " + - $"{PendingPhotosPerUserPerMachineCap} per machine. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending photos for this machine. Maximum is " + + $"{PendingPhotosPerUserPerMachineCap} per machine. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; @@ -609,15 +610,15 @@ public class MachinePhotosController(MarechaiContext context, IConfiguration con string userId = User.FindFirstValue(ClaimTypes.Sid); if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_adminBatchAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF.", statusCode: StatusCodes.Status400BadRequest); bool machineExists = await context.Machines.AnyAsync(m => m.Id == machineId); - if(!machineExists) return NotFound("Machine not found."); + if(!machineExists) return Problem(detail: "Machine not found.", statusCode: StatusCodes.Status404NotFound); // Persist FIRST (with a temporary placeholder for dimensions), then content-sniff // and either re-write the sidecar with real dimensions or reject + delete the file. @@ -634,22 +635,22 @@ public class MachinePhotosController(MarechaiContext context, IConfiguration con if(imagePath is null) { PendingImageStore.Delete(_assetRootPath, "machines", guid); - return BadRequest("Failed to persist uploaded file."); + return Problem(detail: "Failed to persist uploaded file.", statusCode: StatusCodes.Status400BadRequest); } (string magickFormat, int width, int height) = Photos.Identify(imagePath); if(magickFormat is null || !_adminBatchAllowedMagickFormats.Contains(magickFormat)) { PendingImageStore.Delete(_assetRootPath, "machines", guid); - return StatusCode(StatusCodes.Status415UnsupportedMediaType, - "File content does not match a supported image format."); + return Problem(detail: "File content does not match a supported image format.", + statusCode: StatusCodes.Status415UnsupportedMediaType); } byte[] thumbBytes = Photos.GenerateThumbnailJpeg(imagePath); if(thumbBytes is null) { PendingImageStore.Delete(_assetRootPath, "machines", guid); - return BadRequest("Failed to generate thumbnail for the uploaded image."); + return Problem(detail: "Failed to generate thumbnail for the uploaded image.", statusCode: StatusCodes.Status400BadRequest); } await PendingImageStore.StoreThumbnailAsync(_assetRootPath, "machines", guid, thumbBytes); @@ -728,13 +729,13 @@ public class MachinePhotosController(MarechaiContext context, IConfiguration con if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(request is null || request.Items is null || request.Items.Count == 0) - return BadRequest("No items provided."); + return Problem(detail: "No items provided.", statusCode: StatusCodes.Status400BadRequest); bool machineExists = await context.Machines.AnyAsync(m => m.Id == request.MachineId); - if(!machineExists) return NotFound("Machine not found."); + if(!machineExists) return Problem(detail: "Machine not found.", statusCode: StatusCodes.Status404NotFound); bool licenseExists = await context.Licenses.AnyAsync(l => l.Id == request.LicenseId); - if(!licenseExists) return NotFound("License not found."); + if(!licenseExists) return Problem(detail: "License not found.", statusCode: StatusCodes.Status404NotFound); // Validate every pending id belongs to the caller and matches machine/admin scope. foreach(AdminMachinePhotoBatchCommitItemDto item in request.Items) @@ -742,15 +743,15 @@ public class MachinePhotosController(MarechaiContext context, IConfiguration con PendingImageStore.PendingMetadata meta = await PendingImageStore.GetMetadataAsync(_assetRootPath, "machines", item.PendingId); - if(meta is null) return NotFound($"Pending image {item.PendingId} not found."); + if(meta is null) return Problem(detail: $"Pending image {item.PendingId} not found.", statusCode: StatusCodes.Status404NotFound); if(meta.EntityType != (byte)SuggestionEntityType.MachinePhoto) - return BadRequest($"Pending image {item.PendingId} is not a machine photo."); + return Problem(detail: $"Pending image {item.PendingId} is not a machine photo.", statusCode: StatusCodes.Status400BadRequest); if(!meta.IsAdminStaging) - return BadRequest($"Pending image {item.PendingId} is not an admin-staged image."); + return Problem(detail: $"Pending image {item.PendingId} is not an admin-staged image.", statusCode: StatusCodes.Status400BadRequest); if(!string.Equals(meta.UploadedById, userId, StringComparison.Ordinal)) return Forbid(); if(meta.ParentEntityId != request.MachineId) - return BadRequest($"Pending image {item.PendingId} was staged for a different machine."); + return Problem(detail: $"Pending image {item.PendingId} was staged for a different machine.", statusCode: StatusCodes.Status400BadRequest); } Guid jobId = batchJobs.StartJob(userId, request.MachineId, request.Items.Count); diff --git a/Marechai.Server/Controllers/MachineVideosController.cs b/Marechai.Server/Controllers/MachineVideosController.cs index 0177eeef..7d9a4585 100644 --- a/Marechai.Server/Controllers/MachineVideosController.cs +++ b/Marechai.Server/Controllers/MachineVideosController.cs @@ -98,7 +98,7 @@ public class MachineVideosController(MarechaiContext context) : ControllerBase if(userId is null) return Unauthorized(); if(string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.VideoId)) - return BadRequest("Provider and Video ID are required."); + return Problem(detail: "Provider and Video ID are required.", statusCode: StatusCodes.Status400BadRequest); string provider = request.Provider.Trim(); string videoId = request.VideoId.Trim(); @@ -112,7 +112,7 @@ public class MachineVideosController(MarechaiContext context) : ControllerBase v.Provider == provider && v.VideoId == videoId); - if(exists) return Conflict("This video is already linked to this machine."); + if(exists) return Problem(detail: "This video is already linked to this machine.", statusCode: StatusCodes.Status409Conflict); var model = new MachineVideo { diff --git a/Marechai.Server/Controllers/MagazineIssuesController.cs b/Marechai.Server/Controllers/MagazineIssuesController.cs index c081c9c7..88e5d543 100644 --- a/Marechai.Server/Controllers/MagazineIssuesController.cs +++ b/Marechai.Server/Controllers/MagazineIssuesController.cs @@ -389,19 +389,19 @@ public class MagazineIssuesController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); MagazineIssue issue = await context.MagazineIssues.FindAsync(id); @@ -557,18 +557,18 @@ public class MagazineIssuesController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!Marechai.Server.Helpers.PendingImageStore.AllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !Marechai.Server.Helpers.PendingImageStore.AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Verify the targeted issue exists; we don't want stray uploads for nonexistent ids. bool issueExists = await context.MagazineIssues.AsNoTracking().AnyAsync(mi => mi.Id == id); @@ -613,18 +613,18 @@ public class MagazineIssuesController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!Marechai.Server.Helpers.PendingImageStore.AllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !Marechai.Server.Helpers.PendingImageStore.AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Cleanup: each user gets at most ONE pending NEW-issue cover at a time. Replace any // prior upload (keyed on EntityId=0) before storing the new one. diff --git a/Marechai.Server/Controllers/PeopleController.cs b/Marechai.Server/Controllers/PeopleController.cs index a62c61c0..c2a199d9 100644 --- a/Marechai.Server/Controllers/PeopleController.cs +++ b/Marechai.Server/Controllers/PeopleController.cs @@ -1118,19 +1118,19 @@ public class PeopleController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedPhotoExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedPhotoContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); Person person = await context.People.FindAsync(id); @@ -1245,18 +1245,18 @@ public class PeopleController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Verify the targeted person exists; we don't want stray uploads for nonexistent ids. bool personExists = await context.People.AsNoTracking().AnyAsync(p => p.Id == id); @@ -1298,18 +1298,18 @@ public class PeopleController( if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Cleanup: each user gets at most ONE pending NEW-person photo at a time. Replace // any prior upload (keyed on EntityId=0) before storing the new one. diff --git a/Marechai.Server/Controllers/ProcessorPhotosController.cs b/Marechai.Server/Controllers/ProcessorPhotosController.cs index 0c68d47a..c0514ddb 100644 --- a/Marechai.Server/Controllers/ProcessorPhotosController.cs +++ b/Marechai.Server/Controllers/ProcessorPhotosController.cs @@ -257,18 +257,18 @@ public class ProcessorPhotosController(MarechaiContext context, IConfiguration c if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Read the file into memory using var ms = new MemoryStream(); @@ -455,29 +455,30 @@ public class ProcessorPhotosController(MarechaiContext context, IConfiguration c if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool processorExists = await context.Processors.AnyAsync(p => p.Id == processorId); - if(!processorExists) return NotFound("Processor not found."); + if(!processorExists) return Problem(detail: "Processor not found.", statusCode: StatusCodes.Status404NotFound); int currentCount = PendingImageStore.CountByUploaderForParentEntity(_assetRootPath, "processors", userId, (byte)SuggestionEntityType.ProcessorPhoto, processorId); if(currentCount >= PendingPhotosPerUserPerProcessorCap) - return Conflict($"You already have {currentCount} pending photos for this processor. Maximum is " + - $"{PendingPhotosPerUserPerProcessorCap} per processor. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending photos for this processor. Maximum is " + + $"{PendingPhotosPerUserPerProcessorCap} per processor. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; @@ -616,15 +617,15 @@ public class ProcessorPhotosController(MarechaiContext context, IConfiguration c string userId = User.FindFirstValue(ClaimTypes.Sid); if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_adminBatchAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF.", statusCode: StatusCodes.Status400BadRequest); bool processorExists = await context.Processors.AnyAsync(p => p.Id == processorId); - if(!processorExists) return NotFound("Processor not found."); + if(!processorExists) return Problem(detail: "Processor not found.", statusCode: StatusCodes.Status404NotFound); // Persist FIRST (with a temporary placeholder for dimensions), then content-sniff // and either re-write the sidecar with real dimensions or reject + delete the file. @@ -641,22 +642,22 @@ public class ProcessorPhotosController(MarechaiContext context, IConfiguration c if(imagePath is null) { PendingImageStore.Delete(_assetRootPath, "processors", guid); - return BadRequest("Failed to persist uploaded file."); + return Problem(detail: "Failed to persist uploaded file.", statusCode: StatusCodes.Status400BadRequest); } (string magickFormat, int width, int height) = Photos.Identify(imagePath); if(magickFormat is null || !_adminBatchAllowedMagickFormats.Contains(magickFormat)) { PendingImageStore.Delete(_assetRootPath, "processors", guid); - return StatusCode(StatusCodes.Status415UnsupportedMediaType, - "File content does not match a supported image format."); + return Problem(detail: "File content does not match a supported image format.", + statusCode: StatusCodes.Status415UnsupportedMediaType); } byte[] thumbBytes = Photos.GenerateThumbnailJpeg(imagePath); if(thumbBytes is null) { PendingImageStore.Delete(_assetRootPath, "processors", guid); - return BadRequest("Failed to generate thumbnail for the uploaded image."); + return Problem(detail: "Failed to generate thumbnail for the uploaded image.", statusCode: StatusCodes.Status400BadRequest); } await PendingImageStore.StoreThumbnailAsync(_assetRootPath, "processors", guid, thumbBytes); @@ -735,13 +736,13 @@ public class ProcessorPhotosController(MarechaiContext context, IConfiguration c if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(request is null || request.Items is null || request.Items.Count == 0) - return BadRequest("No items provided."); + return Problem(detail: "No items provided.", statusCode: StatusCodes.Status400BadRequest); bool processorExists = await context.Processors.AnyAsync(p => p.Id == request.ProcessorId); - if(!processorExists) return NotFound("Processor not found."); + if(!processorExists) return Problem(detail: "Processor not found.", statusCode: StatusCodes.Status404NotFound); bool licenseExists = await context.Licenses.AnyAsync(l => l.Id == request.LicenseId); - if(!licenseExists) return NotFound("License not found."); + if(!licenseExists) return Problem(detail: "License not found.", statusCode: StatusCodes.Status404NotFound); // Validate every pending id belongs to the caller and matches processor/admin scope. foreach(AdminProcessorPhotoBatchCommitItemDto item in request.Items) @@ -749,15 +750,15 @@ public class ProcessorPhotosController(MarechaiContext context, IConfiguration c PendingImageStore.PendingMetadata meta = await PendingImageStore.GetMetadataAsync(_assetRootPath, "processors", item.PendingId); - if(meta is null) return NotFound($"Pending image {item.PendingId} not found."); + if(meta is null) return Problem(detail: $"Pending image {item.PendingId} not found.", statusCode: StatusCodes.Status404NotFound); if(meta.EntityType != (byte)SuggestionEntityType.ProcessorPhoto) - return BadRequest($"Pending image {item.PendingId} is not a processor photo."); + return Problem(detail: $"Pending image {item.PendingId} is not a processor photo.", statusCode: StatusCodes.Status400BadRequest); if(!meta.IsAdminStaging) - return BadRequest($"Pending image {item.PendingId} is not an admin-staged image."); + return Problem(detail: $"Pending image {item.PendingId} is not an admin-staged image.", statusCode: StatusCodes.Status400BadRequest); if(!string.Equals(meta.UploadedById, userId, StringComparison.Ordinal)) return Forbid(); if(meta.ParentEntityId != request.ProcessorId) - return BadRequest($"Pending image {item.PendingId} was staged for a different processor."); + return Problem(detail: $"Pending image {item.PendingId} was staged for a different processor.", statusCode: StatusCodes.Status400BadRequest); } Guid jobId = batchJobs.StartJob(userId, request.ProcessorId, request.Items.Count); diff --git a/Marechai.Server/Controllers/ProcessorVideosController.cs b/Marechai.Server/Controllers/ProcessorVideosController.cs index 051ca306..ed4b8f9e 100644 --- a/Marechai.Server/Controllers/ProcessorVideosController.cs +++ b/Marechai.Server/Controllers/ProcessorVideosController.cs @@ -98,7 +98,7 @@ public class ProcessorVideosController(MarechaiContext context) : ControllerBase if(userId is null) return Unauthorized(); if(string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.VideoId)) - return BadRequest("Provider and Video ID are required."); + return Problem(detail: "Provider and Video ID are required.", statusCode: StatusCodes.Status400BadRequest); string provider = request.Provider.Trim(); string videoId = request.VideoId.Trim(); @@ -112,7 +112,7 @@ public class ProcessorVideosController(MarechaiContext context) : ControllerBase v.Provider == provider && v.VideoId == videoId); - if(exists) return Conflict("This video is already linked to this processor."); + if(exists) return Problem(detail: "This video is already linked to this processor.", statusCode: StatusCodes.Status409Conflict); var model = new ProcessorVideo { diff --git a/Marechai.Server/Controllers/SoftwareController.cs b/Marechai.Server/Controllers/SoftwareController.cs index b3521a67..26c38a68 100644 --- a/Marechai.Server/Controllers/SoftwareController.cs +++ b/Marechai.Server/Controllers/SoftwareController.cs @@ -1304,7 +1304,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use // Same scope as the orphan-addons grid: we only allow this PATCH on // rows that page would list (a DLC, or a Game carrying a DLC genre). if(model.Kind != SoftwareKind.Dlc && !(model.Kind == SoftwareKind.Game && hasDlcGenre)) - return BadRequest("This endpoint only accepts DLC rows or Game rows carrying a DLC / add-on genre."); + return Problem(detail: "This endpoint only accepts DLC rows or Game rows carrying a DLC / add-on genre.", statusCode: StatusCodes.Status400BadRequest); ActionResult validationError = await ApplyBaseSoftwareLinkAsync(model, request.BaseSoftwareId, dlcGenreIds, HttpContext.RequestAborted); @@ -1344,7 +1344,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use if(userId is null) return Unauthorized(); if(request.SoftwareIds is null || request.SoftwareIds.Count == 0) - return BadRequest("softwareIds must contain at least one id."); + return Problem(detail: "softwareIds must contain at least one id.", statusCode: StatusCodes.Status400BadRequest); int[] dlcGenreIds = await GetDlcGenreIdsAsync(HttpContext.RequestAborted); @@ -1441,7 +1441,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use // invalid state — the operator must reclassify Kind via the full // edit page if they really want to unlink. if(model.Kind == SoftwareKind.Dlc) - return BadRequest("Cannot clear base software on a DLC row. Change Kind first via the full edit page."); + return Problem(detail: "Cannot clear base software on a DLC row. Change Kind first via the full edit page.", statusCode: StatusCodes.Status400BadRequest); model.BaseSoftwareId = null; @@ -1450,21 +1450,21 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use ulong baseId = baseSoftwareId.Value; - if(baseId == model.Id) return BadRequest("Software cannot reference itself as its base."); + if(baseId == model.Id) return Problem(detail: "Software cannot reference itself as its base.", statusCode: StatusCodes.Status400BadRequest); Software baseSw = await context.Softwares.FindAsync([baseId], cancellationToken); - if(baseSw is null) return BadRequest("Base software not found."); + if(baseSw is null) return Problem(detail: "Base software not found.", statusCode: StatusCodes.Status400BadRequest); if(baseSw.Kind == SoftwareKind.Dlc) - return BadRequest("Base software is itself a DLC / add-on; chained DLCs are not allowed."); + return Problem(detail: "Base software is itself a DLC / add-on; chained DLCs are not allowed.", statusCode: StatusCodes.Status400BadRequest); bool baseHasDlcGenre = await context.GenresBySoftware .AnyAsync(g => g.SoftwareId == baseId && dlcGenreIds.Contains(g.GenreId), cancellationToken); if(baseHasDlcGenre) - return BadRequest("Base software is a misclassified Game carrying a DLC / add-on genre."); + return Problem(detail: "Base software is a misclassified Game carrying a DLC / add-on genre.", statusCode: StatusCodes.Status400BadRequest); model.BaseSoftwareId = baseId; @@ -1522,10 +1522,10 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use model.BaseSoftwareId = dto.BaseSoftwareId; if(model.Kind == SoftwareKind.Dlc && model.BaseSoftwareId is null) - return BadRequest("DLC / Addon software must have a base software."); + return Problem(detail: "DLC / Addon software must have a base software.", statusCode: StatusCodes.Status400BadRequest); if(model.Kind != SoftwareKind.Dlc && model.BaseSoftwareId is not null) - return BadRequest("Only DLC / Addon software can have a base software."); + return Problem(detail: "Only DLC / Addon software can have a base software.", statusCode: StatusCodes.Status400BadRequest); await context.News.AddAsync(new News { @@ -1561,10 +1561,10 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use }; if(model.Kind == SoftwareKind.Dlc && model.BaseSoftwareId is null) - return BadRequest("DLC / Addon software must have a base software."); + return Problem(detail: "DLC / Addon software must have a base software.", statusCode: StatusCodes.Status400BadRequest); if(model.Kind != SoftwareKind.Dlc && model.BaseSoftwareId is not null) - return BadRequest("Only DLC / Addon software can have a base software."); + return Problem(detail: "Only DLC / Addon software can have a base software.", statusCode: StatusCodes.Status400BadRequest); await context.Softwares.AddAsync(model); await context.SaveChangesWithUserAsync(userId); @@ -1622,15 +1622,15 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task> GetMergePreviewAsync(ulong targetId, ulong sourceId) { - if(targetId == sourceId) return BadRequest("Cannot merge a software entry into itself."); + if(targetId == sourceId) return Problem(detail: "Cannot merge a software entry into itself.", statusCode: StatusCodes.Status400BadRequest); Software target = await context.Softwares.FindAsync(targetId); - if(target is null) return NotFound("Target software not found."); + if(target is null) return Problem(detail: "Target software not found.", statusCode: StatusCodes.Status404NotFound); Software source = await context.Softwares.FindAsync(sourceId); - if(source is null) return NotFound("Source software not found."); + if(source is null) return Problem(detail: "Source software not found.", statusCode: StatusCodes.Status404NotFound); // Extract suggested release title from name difference string suggestedTitle = source.Name; @@ -1786,18 +1786,18 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use if(userId is null) return Unauthorized(); - if(targetId == sourceId) return BadRequest("Cannot merge a software entry into itself."); + if(targetId == sourceId) return Problem(detail: "Cannot merge a software entry into itself.", statusCode: StatusCodes.Status400BadRequest); Software target = await context.Softwares.FindAsync(targetId); - if(target is null) return NotFound("Target software not found."); + if(target is null) return Problem(detail: "Target software not found.", statusCode: StatusCodes.Status404NotFound); Software source = await context.Softwares.FindAsync(sourceId); - if(source is null) return NotFound("Source software not found."); + if(source is null) return Problem(detail: "Source software not found.", statusCode: StatusCodes.Status404NotFound); if((source.Kind == SoftwareKind.Dlc) != (target.Kind == SoftwareKind.Dlc)) - return Conflict("Cannot merge DLC / Addon software with non-DLC software."); + return Problem(detail: "Cannot merge DLC / Addon software with non-DLC software.", statusCode: StatusCodes.Status409Conflict); await using var transaction = await context.Database.BeginTransactionAsync(); @@ -2886,7 +2886,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use public async Task UpsertMyRatingAsync(ulong id, [FromBody] SetRatingRequest dto) { if(dto.Rating < 0 || dto.Rating > 5) - return BadRequest("Rating must be between 0 and 5."); + return Problem(detail: "Rating must be between 0 and 5.", statusCode: StatusCodes.Status400BadRequest); // Snap to nearest 0.5 float snapped = MathF.Round(dto.Rating * 2f) / 2f; @@ -3036,7 +3036,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use bool exists = await context.SoftwareUserReviews.AnyAsync(r => r.UserId == userId && r.SoftwareId == id); - if(exists) return Conflict("You have already reviewed this software."); + if(exists) return Problem(detail: "You have already reviewed this software.", statusCode: StatusCodes.Status409Conflict); var review = new SoftwareUserReview { @@ -3229,7 +3229,7 @@ public class SoftwareController(MarechaiContext context, IMemoryCache cache, Use bool alreadyReported = await context.ReviewReports.AnyAsync(r => r.ReporterId == userId && r.ReviewId == reviewId); - if(alreadyReported) return Conflict("You have already reported this review."); + if(alreadyReported) return Problem(detail: "You have already reported this review.", statusCode: StatusCodes.Status409Conflict); context.ReviewReports.Add(new ReviewReport { diff --git a/Marechai.Server/Controllers/SoftwareCoversController.cs b/Marechai.Server/Controllers/SoftwareCoversController.cs index b0abac7f..7ec5bb87 100644 --- a/Marechai.Server/Controllers/SoftwareCoversController.cs +++ b/Marechai.Server/Controllers/SoftwareCoversController.cs @@ -183,24 +183,24 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool releaseExists = await context.SoftwareReleases.AnyAsync(r => r.Id == (ulong)releaseId); if(!releaseExists) - return BadRequest("Referenced software release does not exist."); + return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest); using var ms = new MemoryStream(); await file.CopyToAsync(ms); @@ -278,13 +278,13 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co .FirstOrDefaultAsync(r => r.Id == (ulong)dto.SoftwareReleaseId); if(newRelease is null) - return BadRequest("Referenced software release does not exist."); + return Problem(detail: "Referenced software release does not exist.", statusCode: StatusCodes.Status400BadRequest); ulong? currentSoftwareId = model.Release.SoftwareId ?? model.Release.SoftwareVersion?.SoftwareId; ulong? newSoftwareId = newRelease.SoftwareId ?? newRelease.SoftwareVersion?.SoftwareId; if(currentSoftwareId != newSoftwareId) - return BadRequest("Release does not belong to the same software."); + return Problem(detail: "Release does not belong to the same software.", statusCode: StatusCodes.Status400BadRequest); model.SoftwareReleaseId = (ulong)dto.SoftwareReleaseId; } @@ -406,22 +406,22 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool releaseExists = await context.SoftwareReleases.AnyAsync(r => r.Id == releaseId); - if(!releaseExists) return NotFound("Software release not found."); + if(!releaseExists) return Problem(detail: "Software release not found.", statusCode: StatusCodes.Status404NotFound); long parentEntityId = (long)releaseId; @@ -429,8 +429,9 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co userId, (byte)SuggestionEntityType.SoftwareCover, parentEntityId); if(currentCount >= PendingPhotosPerUserPerReleaseCap) - return Conflict($"You already have {currentCount} pending cover images for this release. Maximum is " + - $"{PendingPhotosPerUserPerReleaseCap} per release. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending cover images for this release. Maximum is " + + $"{PendingPhotosPerUserPerReleaseCap} per release. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; @@ -561,15 +562,15 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co string userId = User.FindFirstValue(ClaimTypes.Sid); if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_adminBatchAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF.", statusCode: StatusCodes.Status400BadRequest); bool releaseExists = await context.SoftwareReleases.AnyAsync(r => r.Id == releaseId); - if(!releaseExists) return NotFound("Software release not found."); + if(!releaseExists) return Problem(detail: "Software release not found.", statusCode: StatusCodes.Status404NotFound); // Persist FIRST (with a temporary placeholder for dimensions), then content-sniff // and either re-write the sidecar with real dimensions or reject + delete the file. @@ -586,22 +587,22 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co if(imagePath is null) { PendingImageStore.Delete(_assetRootPath, "software-covers", guid); - return BadRequest("Failed to persist uploaded file."); + return Problem(detail: "Failed to persist uploaded file.", statusCode: StatusCodes.Status400BadRequest); } (string magickFormat, int width, int height) = Photos.Identify(imagePath); if(magickFormat is null || !_adminBatchAllowedMagickFormats.Contains(magickFormat)) { PendingImageStore.Delete(_assetRootPath, "software-covers", guid); - return StatusCode(StatusCodes.Status415UnsupportedMediaType, - "File content does not match a supported image format."); + return Problem(detail: "File content does not match a supported image format.", + statusCode: StatusCodes.Status415UnsupportedMediaType); } byte[] thumbBytes = Photos.GenerateThumbnailJpeg(imagePath); if(thumbBytes is null) { PendingImageStore.Delete(_assetRootPath, "software-covers", guid); - return BadRequest("Failed to generate thumbnail for the uploaded image."); + return Problem(detail: "Failed to generate thumbnail for the uploaded image.", statusCode: StatusCodes.Status400BadRequest); } await PendingImageStore.StoreThumbnailAsync(_assetRootPath, "software-covers", guid, thumbBytes); @@ -681,10 +682,10 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(request is null || request.Items is null || request.Items.Count == 0) - return BadRequest("No items provided."); + return Problem(detail: "No items provided.", statusCode: StatusCodes.Status400BadRequest); bool releaseExists = await context.SoftwareReleases.AnyAsync(r => r.Id == request.SoftwareReleaseId); - if(!releaseExists) return NotFound("Software release not found."); + if(!releaseExists) return Problem(detail: "Software release not found.", statusCode: StatusCodes.Status404NotFound); // Validate every pending id belongs to the caller and matches release/admin scope. foreach(AdminBatchCommitItemDto item in request.Items) @@ -692,17 +693,17 @@ public class SoftwareCoversController(MarechaiContext context, IConfiguration co PendingImageStore.PendingMetadata meta = await PendingImageStore.GetMetadataAsync(_assetRootPath, "software-covers", item.PendingId); - if(meta is null) return NotFound($"Pending image {item.PendingId} not found."); + if(meta is null) return Problem(detail: $"Pending image {item.PendingId} not found.", statusCode: StatusCodes.Status404NotFound); if(meta.EntityType != (byte)SuggestionEntityType.SoftwareCover) - return BadRequest($"Pending image {item.PendingId} is not a software cover."); + return Problem(detail: $"Pending image {item.PendingId} is not a software cover.", statusCode: StatusCodes.Status400BadRequest); if(!meta.IsAdminStaging) - return BadRequest($"Pending image {item.PendingId} is not an admin-staged image."); + return Problem(detail: $"Pending image {item.PendingId} is not an admin-staged image.", statusCode: StatusCodes.Status400BadRequest); if(!string.Equals(meta.UploadedById, userId, StringComparison.Ordinal)) return Forbid(); if(meta.ParentEntityId != (long)request.SoftwareReleaseId) - return BadRequest($"Pending image {item.PendingId} was staged for a different release."); + return Problem(detail: $"Pending image {item.PendingId} was staged for a different release.", statusCode: StatusCodes.Status400BadRequest); if(!Enum.IsDefined(typeof(SoftwareCoverType), (byte)item.Type)) - return BadRequest($"Invalid cover type {item.Type} for pending image {item.PendingId}."); + return Problem(detail: $"Invalid cover type {item.Type} for pending image {item.PendingId}.", statusCode: StatusCodes.Status400BadRequest); } Guid jobId = batchJobs.StartJob(userId, (long)request.SoftwareReleaseId, request.Items.Count); diff --git a/Marechai.Server/Controllers/SoftwarePromoArtController.cs b/Marechai.Server/Controllers/SoftwarePromoArtController.cs index bdd9799d..76ae4288 100644 --- a/Marechai.Server/Controllers/SoftwarePromoArtController.cs +++ b/Marechai.Server/Controllers/SoftwarePromoArtController.cs @@ -192,32 +192,32 @@ public class SoftwarePromoArtController(MarechaiContext context, IConfiguration if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); string trimmedGroup = groupName?.Trim(); if(string.IsNullOrWhiteSpace(trimmedGroup)) - return BadRequest("Group name is required."); + return Problem(detail: "Group name is required.", statusCode: StatusCodes.Status400BadRequest); if(trimmedGroup.Length > 256) - return BadRequest("Group name exceeds 256 characters."); + return Problem(detail: "Group name exceeds 256 characters.", statusCode: StatusCodes.Status400BadRequest); bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == softwareId); if(!softwareExists) - return BadRequest("Referenced software does not exist."); + return Problem(detail: "Referenced software does not exist.", statusCode: StatusCodes.Status400BadRequest); SoftwarePromoArtGroup group = await context.SoftwarePromoArtGroups.FirstOrDefaultAsync(g => g.Name == trimmedGroup); @@ -301,10 +301,10 @@ public class SoftwarePromoArtController(MarechaiContext context, IConfiguration string trimmedGroup = dto.GroupName.Trim(); if(string.IsNullOrWhiteSpace(trimmedGroup)) - return BadRequest("Group name cannot be empty."); + return Problem(detail: "Group name cannot be empty.", statusCode: StatusCodes.Status400BadRequest); if(trimmedGroup.Length > 256) - return BadRequest("Group name exceeds 256 characters."); + return Problem(detail: "Group name exceeds 256 characters.", statusCode: StatusCodes.Status400BadRequest); SoftwarePromoArtGroup group = await context.SoftwarePromoArtGroups.FirstOrDefaultAsync(g => g.Name == trimmedGroup); @@ -459,22 +459,22 @@ public class SoftwarePromoArtController(MarechaiContext context, IConfiguration if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == softwareId); - if(!softwareExists) return NotFound("Software not found."); + if(!softwareExists) return Problem(detail: "Software not found.", statusCode: StatusCodes.Status404NotFound); long parentEntityId = (long)softwareId; @@ -482,8 +482,9 @@ public class SoftwarePromoArtController(MarechaiContext context, IConfiguration userId, (byte)SuggestionEntityType.SoftwarePromoArt, parentEntityId); if(currentCount >= PendingPhotosPerUserPerSoftwareCap) - return Conflict($"You already have {currentCount} pending promo art images for this software. Maximum " + - $"is {PendingPhotosPerUserPerSoftwareCap} per software. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending promo art images for this software. Maximum " + + $"is {PendingPhotosPerUserPerSoftwareCap} per software. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; diff --git a/Marechai.Server/Controllers/SoftwareReleasesController.cs b/Marechai.Server/Controllers/SoftwareReleasesController.cs index 63624660..90442084 100644 --- a/Marechai.Server/Controllers/SoftwareReleasesController.cs +++ b/Marechai.Server/Controllers/SoftwareReleasesController.cs @@ -362,11 +362,11 @@ public class SoftwareReleasesController(MarechaiContext contex // Enforce mutual exclusivity: cannot change IsCompilation after creation if(model.IsCompilation != dto.IsCompilation) - return BadRequest("Cannot change a release between single-release and compilation modes."); + return Problem(detail: "Cannot change a release between single-release and compilation modes.", statusCode: StatusCodes.Status400BadRequest); // Cannot change SoftwareId after creation if(model.SoftwareId != dto.SoftwareId) - return BadRequest("Cannot change the software associated with a release."); + return Problem(detail: "Cannot change the software associated with a release.", statusCode: StatusCodes.Status400BadRequest); model.Title = dto.Title; model.SoftwareVersionId = dto.SoftwareVersionId; @@ -497,24 +497,24 @@ public class SoftwareReleasesController(MarechaiContext contex if(release is null) return NotFound(); if(!release.IsCompilation) - return BadRequest("Cannot add included versions to a non-compilation release."); + return Problem(detail: "Cannot add included versions to a non-compilation release.", statusCode: StatusCodes.Status400BadRequest); // Ensure this is a versioned compilation (not a versionless one) bool hasIncludedSoftware = await context.SoftwareBySoftwareRelease .AnyAsync(x => x.ReleaseId == releaseId); if(hasIncludedSoftware) - return BadRequest("Cannot add included versions to a versionless compilation. Use included software instead."); + return Problem(detail: "Cannot add included versions to a versionless compilation. Use included software instead.", statusCode: StatusCodes.Status400BadRequest); bool exists = await context.SoftwareVersionBySoftwareRelease .AnyAsync(x => x.ReleaseId == releaseId && x.SoftwareVersionId == dto.SoftwareVersionId); - if(exists) return BadRequest("This version is already included in the release."); + if(exists) return Problem(detail: "This version is already included in the release.", statusCode: StatusCodes.Status400BadRequest); bool versionExists = await context.SoftwareVersions.AnyAsync(v => v.Id == dto.SoftwareVersionId); - if(!versionExists) return BadRequest("The specified software version does not exist."); + if(!versionExists) return Problem(detail: "The specified software version does not exist.", statusCode: StatusCodes.Status400BadRequest); await context.SoftwareVersionBySoftwareRelease.AddAsync(new SoftwareVersionBySoftwareRelease { @@ -544,7 +544,7 @@ public class SoftwareReleasesController(MarechaiContext contex if(release is null) return NotFound(); if(!release.IsCompilation) - return BadRequest("Cannot remove included versions from a non-compilation release."); + return Problem(detail: "Cannot remove included versions from a non-compilation release.", statusCode: StatusCodes.Status400BadRequest); SoftwareVersionBySoftwareRelease entry = await context.SoftwareVersionBySoftwareRelease @@ -675,24 +675,24 @@ public class SoftwareReleasesController(MarechaiContext contex if(release is null) return NotFound(); if(!release.IsCompilation) - return BadRequest("Cannot add included software to a non-compilation release."); + return Problem(detail: "Cannot add included software to a non-compilation release.", statusCode: StatusCodes.Status400BadRequest); // Ensure this is a versionless compilation (not a versioned one) bool hasIncludedVersions = await context.SoftwareVersionBySoftwareRelease .AnyAsync(x => x.ReleaseId == releaseId); if(hasIncludedVersions) - return BadRequest("Cannot add included software to a versioned compilation. Use included versions instead."); + return Problem(detail: "Cannot add included software to a versioned compilation. Use included versions instead.", statusCode: StatusCodes.Status400BadRequest); bool exists = await context.SoftwareBySoftwareRelease .AnyAsync(x => x.ReleaseId == releaseId && x.SoftwareId == dto.SoftwareId); - if(exists) return BadRequest("This software is already included in the release."); + if(exists) return Problem(detail: "This software is already included in the release.", statusCode: StatusCodes.Status400BadRequest); bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == dto.SoftwareId); - if(!softwareExists) return BadRequest("The specified software does not exist."); + if(!softwareExists) return Problem(detail: "The specified software does not exist.", statusCode: StatusCodes.Status400BadRequest); await context.SoftwareBySoftwareRelease.AddAsync(new SoftwareBySoftwareRelease { @@ -722,7 +722,7 @@ public class SoftwareReleasesController(MarechaiContext contex if(release is null) return NotFound(); if(!release.IsCompilation) - return BadRequest("Cannot remove included software from a non-compilation release."); + return Problem(detail: "Cannot remove included software from a non-compilation release.", statusCode: StatusCodes.Status400BadRequest); SoftwareBySoftwareRelease entry = await context.SoftwareBySoftwareRelease @@ -858,11 +858,11 @@ public class SoftwareReleasesController(MarechaiContext contex .AnyAsync(x => x.SoftwareReleaseId == releaseId && x.UnM49Id == dto.UnM49Id); - if(exists) return BadRequest("This region is already assigned to the release."); + if(exists) return Problem(detail: "This region is already assigned to the release.", statusCode: StatusCodes.Status400BadRequest); bool regionExists = await context.UnM49.AnyAsync(r => r.Id == dto.UnM49Id); - if(!regionExists) return BadRequest("The specified region does not exist."); + if(!regionExists) return Problem(detail: "The specified region does not exist.", statusCode: StatusCodes.Status400BadRequest); await context.UnM49BySoftwareRelease.AddAsync(new UnM49BySoftwareRelease { @@ -937,11 +937,11 @@ public class SoftwareReleasesController(MarechaiContext contex .AnyAsync(x => x.SoftwareReleaseId == releaseId && x.LanguageCode == dto.LanguageCode); - if(exists) return BadRequest("This language is already assigned to the release."); + if(exists) return Problem(detail: "This language is already assigned to the release.", statusCode: StatusCodes.Status400BadRequest); bool languageExists = await context.Iso639.AnyAsync(l => l.Id == dto.LanguageCode); - if(!languageExists) return BadRequest("The specified language does not exist."); + if(!languageExists) return Problem(detail: "The specified language does not exist.", statusCode: StatusCodes.Status400BadRequest); await context.LanguageBySoftwareRelease.AddAsync(new LanguageBySoftwareRelease { diff --git a/Marechai.Server/Controllers/SoftwareScreenshotsController.cs b/Marechai.Server/Controllers/SoftwareScreenshotsController.cs index 7286ce5a..0206a49e 100644 --- a/Marechai.Server/Controllers/SoftwareScreenshotsController.cs +++ b/Marechai.Server/Controllers/SoftwareScreenshotsController.cs @@ -296,32 +296,32 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Validate that the referenced software exists bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == softwareId); if(!softwareExists) - return BadRequest("Referenced software does not exist."); + return Problem(detail: "Referenced software does not exist.", statusCode: StatusCodes.Status400BadRequest); if(softwarePlatformId.HasValue) { bool platformExists = await context.SoftwarePlatforms.AnyAsync(p => p.Id == softwarePlatformId.Value); if(!platformExists) - return BadRequest("Referenced software platform does not exist."); + return Problem(detail: "Referenced software platform does not exist.", statusCode: StatusCodes.Status400BadRequest); } if(softwareVersionId.HasValue) @@ -330,7 +330,7 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu v.SoftwareId == softwareId); if(!versionExists) - return BadRequest("Referenced software version does not exist or does not belong to this software."); + return Problem(detail: "Referenced software version does not exist or does not belong to this software.", statusCode: StatusCodes.Status400BadRequest); } // Optional group: resolve-or-create by canonical English name. Mirrors the PromoArt @@ -633,22 +633,22 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == softwareId); - if(!softwareExists) return NotFound("Software not found."); + if(!softwareExists) return Problem(detail: "Software not found.", statusCode: StatusCodes.Status404NotFound); long parentEntityId = (long)softwareId; @@ -656,8 +656,9 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu userId, (byte)SuggestionEntityType.SoftwareScreenshot, parentEntityId); if(currentCount >= PendingScreenshotsPerUserPerSoftwareCap) - return Conflict($"You already have {currentCount} pending screenshot images for this software. Maximum " + - $"is {PendingScreenshotsPerUserPerSoftwareCap} per software. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending screenshot images for this software. Maximum " + + $"is {PendingScreenshotsPerUserPerSoftwareCap} per software. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; @@ -788,15 +789,15 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu string userId = User.FindFirstValue(ClaimTypes.Sid); if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_adminBatchAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF.", statusCode: StatusCodes.Status400BadRequest); bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == (ulong)softwareId); - if(!softwareExists) return NotFound("Software not found."); + if(!softwareExists) return Problem(detail: "Software not found.", statusCode: StatusCodes.Status404NotFound); // Persist FIRST (with a temporary placeholder for dimensions), then content-sniff // and either re-write the sidecar with real dimensions or reject + delete the file. @@ -813,22 +814,22 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu if(imagePath is null) { PendingImageStore.Delete(_assetRootPath, "software-screenshots", guid); - return BadRequest("Failed to persist uploaded file."); + return Problem(detail: "Failed to persist uploaded file.", statusCode: StatusCodes.Status400BadRequest); } (string magickFormat, int width, int height) = Photos.Identify(imagePath); if(magickFormat is null || !_adminBatchAllowedMagickFormats.Contains(magickFormat)) { PendingImageStore.Delete(_assetRootPath, "software-screenshots", guid); - return StatusCode(StatusCodes.Status415UnsupportedMediaType, - "File content does not match a supported image format."); + return Problem(detail: "File content does not match a supported image format.", + statusCode: StatusCodes.Status415UnsupportedMediaType); } byte[] thumbBytes = Photos.GenerateThumbnailJpeg(imagePath); if(thumbBytes is null) { PendingImageStore.Delete(_assetRootPath, "software-screenshots", guid); - return BadRequest("Failed to generate thumbnail for the uploaded image."); + return Problem(detail: "Failed to generate thumbnail for the uploaded image.", statusCode: StatusCodes.Status400BadRequest); } await PendingImageStore.StoreThumbnailAsync(_assetRootPath, "software-screenshots", guid, thumbBytes); @@ -907,16 +908,16 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(request is null || request.Items is null || request.Items.Count == 0) - return BadRequest("No items provided."); + return Problem(detail: "No items provided.", statusCode: StatusCodes.Status400BadRequest); bool softwareExists = await context.Softwares.AnyAsync(s => s.Id == (ulong)request.SoftwareId); - if(!softwareExists) return NotFound("Software not found."); + if(!softwareExists) return Problem(detail: "Software not found.", statusCode: StatusCodes.Status404NotFound); if(request.SoftwarePlatformId.HasValue) { ulong platformId = (ulong)request.SoftwarePlatformId.Value; bool platformExists = await context.SoftwarePlatforms.AnyAsync(p => p.Id == platformId); - if(!platformExists) return NotFound("Software platform not found."); + if(!platformExists) return Problem(detail: "Software platform not found.", statusCode: StatusCodes.Status404NotFound); } if(request.SoftwareVersionId.HasValue) @@ -925,7 +926,7 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu ulong softwareIdScope = (ulong)request.SoftwareId; bool versionExists = await context.SoftwareVersions.AnyAsync(v => v.Id == versionId && v.SoftwareId == softwareIdScope); - if(!versionExists) return NotFound("Software version not found."); + if(!versionExists) return Problem(detail: "Software version not found.", statusCode: StatusCodes.Status404NotFound); } // Validate every pending id belongs to the caller and matches software/admin scope. @@ -934,15 +935,15 @@ public class SoftwareScreenshotsController(MarechaiContext context, IConfigu PendingImageStore.PendingMetadata meta = await PendingImageStore.GetMetadataAsync(_assetRootPath, "software-screenshots", item.PendingId); - if(meta is null) return NotFound($"Pending image {item.PendingId} not found."); + if(meta is null) return Problem(detail: $"Pending image {item.PendingId} not found.", statusCode: StatusCodes.Status404NotFound); if(meta.EntityType != (byte)SuggestionEntityType.SoftwareScreenshot) - return BadRequest($"Pending image {item.PendingId} is not a software screenshot."); + return Problem(detail: $"Pending image {item.PendingId} is not a software screenshot.", statusCode: StatusCodes.Status400BadRequest); if(!meta.IsAdminStaging) - return BadRequest($"Pending image {item.PendingId} is not an admin-staged image."); + return Problem(detail: $"Pending image {item.PendingId} is not an admin-staged image.", statusCode: StatusCodes.Status400BadRequest); if(!string.Equals(meta.UploadedById, userId, StringComparison.Ordinal)) return Forbid(); if(meta.ParentEntityId != request.SoftwareId) - return BadRequest($"Pending image {item.PendingId} was staged for a different software."); + return Problem(detail: $"Pending image {item.PendingId} was staged for a different software.", statusCode: StatusCodes.Status400BadRequest); } Guid jobId = batchJobs.StartJob(userId, request.SoftwareId, request.Items.Count); diff --git a/Marechai.Server/Controllers/SoftwareVideosController.cs b/Marechai.Server/Controllers/SoftwareVideosController.cs index 086a1336..41da4946 100644 --- a/Marechai.Server/Controllers/SoftwareVideosController.cs +++ b/Marechai.Server/Controllers/SoftwareVideosController.cs @@ -98,7 +98,7 @@ public class SoftwareVideosController(MarechaiContext context) : ControllerBase if(userId is null) return Unauthorized(); if(string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.VideoId)) - return BadRequest("Provider and Video ID are required."); + return Problem(detail: "Provider and Video ID are required.", statusCode: StatusCodes.Status400BadRequest); string provider = request.Provider.Trim(); string videoId = request.VideoId.Trim(); @@ -112,7 +112,7 @@ public class SoftwareVideosController(MarechaiContext context) : ControllerBase v.Provider == provider && v.VideoId == videoId); - if(exists) return Conflict("This video is already linked to this software."); + if(exists) return Problem(detail: "This video is already linked to this software.", statusCode: StatusCodes.Status409Conflict); var model = new SoftwareVideo { diff --git a/Marechai.Server/Controllers/SoundSynthPhotosController.cs b/Marechai.Server/Controllers/SoundSynthPhotosController.cs index 68c6dfe3..868f7d53 100644 --- a/Marechai.Server/Controllers/SoundSynthPhotosController.cs +++ b/Marechai.Server/Controllers/SoundSynthPhotosController.cs @@ -257,18 +257,18 @@ public class SoundSynthPhotosController(MarechaiContext context, IConfiguration if(userId is null) return Unauthorized(); if(file is null || file.Length == 0) - return BadRequest("No file provided."); + return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); if(file.Length > 50 * 1024 * 1024) - return BadRequest("File exceeds 50 MB limit."); + return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_allowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, TIFF, BMP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_allowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); // Read the file into memory using var ms = new MemoryStream(); @@ -455,29 +455,30 @@ public class SoundSynthPhotosController(MarechaiContext context, IConfiguration if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_pendingAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP.", statusCode: StatusCodes.Status400BadRequest); if(!string.IsNullOrEmpty(file.ContentType) && !_pendingAllowedContentTypes.Contains(file.ContentType.ToLowerInvariant())) - return BadRequest("Unsupported content type."); + return Problem(detail: "Unsupported content type.", statusCode: StatusCodes.Status400BadRequest); bool soundSynthExists = await context.SoundSynths.AnyAsync(s => s.Id == soundSynthId); - if(!soundSynthExists) return NotFound("Sound synthesizer not found."); + if(!soundSynthExists) return Problem(detail: "Sound synthesizer not found.", statusCode: StatusCodes.Status404NotFound); int currentCount = PendingImageStore.CountByUploaderForParentEntity(_assetRootPath, "sound-synths", userId, (byte)SuggestionEntityType.SoundSynthPhoto, soundSynthId); if(currentCount >= PendingPhotosPerUserPerSoundSynthCap) - return Conflict($"You already have {currentCount} pending photos for this sound synth. Maximum is " + - $"{PendingPhotosPerUserPerSoundSynthCap} per sound synth. Submit or remove some first."); + return Problem(detail: $"You already have {currentCount} pending photos for this sound synth. Maximum is " + + $"{PendingPhotosPerUserPerSoundSynthCap} per sound synth. Submit or remove some first.", + statusCode: StatusCodes.Status409Conflict); Guid guid; @@ -616,15 +617,15 @@ public class SoundSynthPhotosController(MarechaiContext context, IConfiguration string userId = User.FindFirstValue(ClaimTypes.Sid); if(string.IsNullOrEmpty(userId)) return Unauthorized(); - if(file is null || file.Length == 0) return BadRequest("No file provided."); - if(file.Length > 50 * 1024 * 1024) return BadRequest("File exceeds 50 MB limit."); + if(file is null || file.Length == 0) return Problem(detail: "No file provided.", statusCode: StatusCodes.Status400BadRequest); + if(file.Length > 50 * 1024 * 1024) return Problem(detail: "File exceeds 50 MB limit.", statusCode: StatusCodes.Status400BadRequest); string extension = Path.GetExtension(file.FileName)?.ToLowerInvariant() ?? string.Empty; if(!_adminBatchAllowedExtensions.Contains(extension)) - return BadRequest("Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF."); + return Problem(detail: "Unsupported file format. Accepted: JPEG, PNG, WebP, AVIF, BMP, TIFF.", statusCode: StatusCodes.Status400BadRequest); bool soundSynthExists = await context.SoundSynths.AnyAsync(s => s.Id == soundSynthId); - if(!soundSynthExists) return NotFound("Sound synthesizer not found."); + if(!soundSynthExists) return Problem(detail: "Sound synthesizer not found.", statusCode: StatusCodes.Status404NotFound); // Persist FIRST (with a temporary placeholder for dimensions), then content-sniff // and either re-write the sidecar with real dimensions or reject + delete the file. @@ -641,22 +642,22 @@ public class SoundSynthPhotosController(MarechaiContext context, IConfiguration if(imagePath is null) { PendingImageStore.Delete(_assetRootPath, "sound-synths", guid); - return BadRequest("Failed to persist uploaded file."); + return Problem(detail: "Failed to persist uploaded file.", statusCode: StatusCodes.Status400BadRequest); } (string magickFormat, int width, int height) = Photos.Identify(imagePath); if(magickFormat is null || !_adminBatchAllowedMagickFormats.Contains(magickFormat)) { PendingImageStore.Delete(_assetRootPath, "sound-synths", guid); - return StatusCode(StatusCodes.Status415UnsupportedMediaType, - "File content does not match a supported image format."); + return Problem(detail: "File content does not match a supported image format.", + statusCode: StatusCodes.Status415UnsupportedMediaType); } byte[] thumbBytes = Photos.GenerateThumbnailJpeg(imagePath); if(thumbBytes is null) { PendingImageStore.Delete(_assetRootPath, "sound-synths", guid); - return BadRequest("Failed to generate thumbnail for the uploaded image."); + return Problem(detail: "Failed to generate thumbnail for the uploaded image.", statusCode: StatusCodes.Status400BadRequest); } await PendingImageStore.StoreThumbnailAsync(_assetRootPath, "sound-synths", guid, thumbBytes); @@ -735,13 +736,13 @@ public class SoundSynthPhotosController(MarechaiContext context, IConfiguration if(string.IsNullOrEmpty(userId)) return Unauthorized(); if(request is null || request.Items is null || request.Items.Count == 0) - return BadRequest("No items provided."); + return Problem(detail: "No items provided.", statusCode: StatusCodes.Status400BadRequest); bool soundSynthExists = await context.SoundSynths.AnyAsync(s => s.Id == request.SoundSynthId); - if(!soundSynthExists) return NotFound("Sound synthesizer not found."); + if(!soundSynthExists) return Problem(detail: "Sound synthesizer not found.", statusCode: StatusCodes.Status404NotFound); bool licenseExists = await context.Licenses.AnyAsync(l => l.Id == request.LicenseId); - if(!licenseExists) return NotFound("License not found."); + if(!licenseExists) return Problem(detail: "License not found.", statusCode: StatusCodes.Status404NotFound); // Validate every pending id belongs to the caller and matches sound-synth/admin scope. foreach(AdminSoundSynthPhotoBatchCommitItemDto item in request.Items) @@ -749,16 +750,16 @@ public class SoundSynthPhotosController(MarechaiContext context, IConfiguration PendingImageStore.PendingMetadata meta = await PendingImageStore.GetMetadataAsync(_assetRootPath, "sound-synths", item.PendingId); - if(meta is null) return NotFound($"Pending image {item.PendingId} not found."); + if(meta is null) return Problem(detail: $"Pending image {item.PendingId} not found.", statusCode: StatusCodes.Status404NotFound); if(meta.EntityType != (byte)SuggestionEntityType.SoundSynthPhoto) - return BadRequest($"Pending image {item.PendingId} is not a sound synth photo."); + return Problem(detail: $"Pending image {item.PendingId} is not a sound synth photo.", statusCode: StatusCodes.Status400BadRequest); if(!meta.IsAdminStaging) - return BadRequest($"Pending image {item.PendingId} is not an admin-staged image."); + return Problem(detail: $"Pending image {item.PendingId} is not an admin-staged image.", statusCode: StatusCodes.Status400BadRequest); if(!string.Equals(meta.UploadedById, userId, StringComparison.Ordinal)) return Forbid(); if(meta.ParentEntityId != request.SoundSynthId) - return BadRequest( - $"Pending image {item.PendingId} was staged for a different sound synthesizer."); + return Problem(detail: $"Pending image {item.PendingId} was staged for a different sound synthesizer.", + statusCode: StatusCodes.Status400BadRequest); } Guid jobId = batchJobs.StartJob(userId, request.SoundSynthId, request.Items.Count); diff --git a/Marechai.Server/Controllers/SoundSynthVideosController.cs b/Marechai.Server/Controllers/SoundSynthVideosController.cs index 70e67650..dc33e01f 100644 --- a/Marechai.Server/Controllers/SoundSynthVideosController.cs +++ b/Marechai.Server/Controllers/SoundSynthVideosController.cs @@ -98,7 +98,7 @@ public class SoundSynthVideosController(MarechaiContext context) : ControllerBas if(userId is null) return Unauthorized(); if(string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.VideoId)) - return BadRequest("Provider and Video ID are required."); + return Problem(detail: "Provider and Video ID are required.", statusCode: StatusCodes.Status400BadRequest); string provider = request.Provider.Trim(); string videoId = request.VideoId.Trim(); @@ -112,7 +112,7 @@ public class SoundSynthVideosController(MarechaiContext context) : ControllerBas v.Provider == provider && v.VideoId == videoId); - if(exists) return Conflict("This video is already linked to this sound synth."); + if(exists) return Problem(detail: "This video is already linked to this sound synth.", statusCode: StatusCodes.Status409Conflict); var model = new SoundSynthVideo { diff --git a/Marechai.Server/Controllers/UsersController.cs b/Marechai.Server/Controllers/UsersController.cs index b0ff1b38..4eb8cd76 100644 --- a/Marechai.Server/Controllers/UsersController.cs +++ b/Marechai.Server/Controllers/UsersController.cs @@ -263,7 +263,7 @@ public class UsersController(UserManager userManager, { ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); IList roles = await userManager.GetRolesAsync(user); @@ -346,7 +346,7 @@ public class UsersController(UserManager userManager, ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); user.UserName = request.UserName; user.Email = request.Email; @@ -389,7 +389,7 @@ public class UsersController(UserManager userManager, { ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); // Delegate to the shared deletion service so the admin path applies the same anonymise-content + // hard-delete-state + avatar-file-cleanup as the GDPR self-service path. The admin path is @@ -398,7 +398,7 @@ public class UsersController(UserManager userManager, bool ok = await userAccountDeletionService.PurgeAsync(id, actorUserIdForLog: actorId ?? "admin"); - if(!ok) return BadRequest("Failed to purge user account."); + if(!ok) return Problem(detail: "Failed to purge user account.", statusCode: StatusCodes.Status400BadRequest); return NoContent(); } @@ -416,7 +416,7 @@ public class UsersController(UserManager userManager, ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); // Remove old password and set new one IdentityResult removeResult = await userManager.RemovePasswordAsync(user); @@ -443,7 +443,7 @@ public class UsersController(UserManager userManager, ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); IdentityResult result = await userManager.AddToRoleAsync(user, request.RoleName); @@ -462,7 +462,7 @@ public class UsersController(UserManager userManager, { ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); IdentityResult result = await userManager.RemoveFromRoleAsync(user, roleName); @@ -685,7 +685,7 @@ public class UsersController(UserManager userManager, { ApplicationUser user = await userManager.FindByIdAsync(id); - if(user == null) return NotFound("User not found"); + if(user == null) return Problem(detail: "User not found", statusCode: StatusCodes.Status404NotFound); await userManager.SetTwoFactorEnabledAsync(user, false); await userManager.ResetAuthenticatorKeyAsync(user); diff --git a/Marechai/Services/BooksService.cs b/Marechai/Services/BooksService.cs index e2ce9d1b..1c72bac0 100644 --- a/Marechai/Services/BooksService.cs +++ b/Marechai/Services/BooksService.cs @@ -356,7 +356,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -374,7 +374,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -392,7 +392,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -426,7 +426,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -444,7 +444,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -464,7 +464,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -482,7 +482,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -502,7 +502,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -520,7 +520,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -540,7 +540,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -558,7 +558,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -578,7 +578,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -596,7 +596,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -616,7 +616,7 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -697,4 +697,21 @@ public class BooksService(Marechai.ApiClient.Client client, ReferenceDataCache r public Task> GetAllMachineFamiliesAsync() => referenceData.GetMachineFamiliesAsync(); public Task> GetCountriesAsync() => referenceData.GetCountriesAsync(); + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/DocumentsService.cs b/Marechai/Services/DocumentsService.cs index 356c28e3..3f38e1ab 100644 --- a/Marechai/Services/DocumentsService.cs +++ b/Marechai/Services/DocumentsService.cs @@ -337,7 +337,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -355,7 +355,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -373,7 +373,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -407,7 +407,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -425,7 +425,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -445,7 +445,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -463,7 +463,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -483,7 +483,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -501,7 +501,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -521,7 +521,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -539,7 +539,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -559,7 +559,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -577,7 +577,7 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -646,4 +646,21 @@ public class DocumentsService(Marechai.ApiClient.Client client, ReferenceDataCac public Task> GetAllMachineFamiliesAsync() => referenceData.GetMachineFamiliesAsync(); public Task> GetCountriesAsync() => referenceData.GetCountriesAsync(); + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/GpusService.cs b/Marechai/Services/GpusService.cs index ae8fe16e..225e6e39 100644 --- a/Marechai/Services/GpusService.cs +++ b/Marechai/Services/GpusService.cs @@ -146,7 +146,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -164,7 +164,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -182,7 +182,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -229,7 +229,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -247,7 +247,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -358,7 +358,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -376,7 +376,7 @@ public class GpusService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { diff --git a/Marechai/Services/InstructionSetExtensionsService.cs b/Marechai/Services/InstructionSetExtensionsService.cs index 646cf632..67918419 100644 --- a/Marechai/Services/InstructionSetExtensionsService.cs +++ b/Marechai/Services/InstructionSetExtensionsService.cs @@ -69,7 +69,7 @@ public class InstructionSetExtensionsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -87,7 +87,7 @@ public class InstructionSetExtensionsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -105,7 +105,7 @@ public class InstructionSetExtensionsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -126,4 +126,21 @@ public class InstructionSetExtensionsService(Marechai.ApiClient.Client client) return false; } } + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/MachineFamiliesService.cs b/Marechai/Services/MachineFamiliesService.cs index 36cf4277..41732285 100644 --- a/Marechai/Services/MachineFamiliesService.cs +++ b/Marechai/Services/MachineFamiliesService.cs @@ -59,7 +59,7 @@ public class MachineFamiliesService(Marechai.ApiClient.Client client, ReferenceD } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -79,7 +79,7 @@ public class MachineFamiliesService(Marechai.ApiClient.Client client, ReferenceD } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -99,7 +99,7 @@ public class MachineFamiliesService(Marechai.ApiClient.Client client, ReferenceD } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -134,4 +134,21 @@ public class MachineFamiliesService(Marechai.ApiClient.Client client, ReferenceD return []; } } + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/MagazinesService.cs b/Marechai/Services/MagazinesService.cs index 8530b11f..529711a7 100644 --- a/Marechai/Services/MagazinesService.cs +++ b/Marechai/Services/MagazinesService.cs @@ -354,7 +354,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -372,7 +372,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -390,7 +390,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -503,7 +503,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -525,7 +525,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -547,7 +547,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -567,7 +567,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -613,7 +613,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -631,7 +631,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -651,7 +651,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -669,7 +669,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -689,7 +689,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -707,7 +707,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -727,7 +727,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -745,7 +745,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -765,7 +765,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -783,7 +783,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -803,7 +803,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -821,7 +821,7 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -905,6 +905,23 @@ public class MagazinesService(Marechai.ApiClient.Client client, ReferenceDataCac public Task> GetCountriesAsync() => referenceData.GetCountriesAsync(); + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } + /// /// Renders a Kiota-thrown into a human-readable string for /// surfacing in toasts / inline error messages. The Kiota-generated ProblemDetails diff --git a/Marechai/Services/OldDosImportsService.cs b/Marechai/Services/OldDosImportsService.cs index b4ef796e..37708b4a 100644 --- a/Marechai/Services/OldDosImportsService.cs +++ b/Marechai/Services/OldDosImportsService.cs @@ -135,19 +135,37 @@ public sealed class OldDosImportsService(Client client, ILogger /// Build a human-readable diagnostic from a Kiota . Includes the HTTP - /// status code plus, when present, a short slice of ex.Message (which usually carries the - /// server's response body) so the admin dialog shows more than just HTTP 404. When the - /// message is empty the endpoint path is appended so it's clear which call failed. + /// status code plus, when present, a short slice of the extracted ProblemDetails detail / title + /// (which is the human-friendly text the server intended to surface) so the admin dialog shows + /// more than just HTTP 404. When the message is empty the endpoint path is appended so + /// it's clear which call failed. /// static string BuildErrorMessage(string endpoint, ApiException ex) { string status = ex.ResponseStatusCode > 0 ? $"HTTP {ex.ResponseStatusCode}" : "HTTP error"; - string msg = ex.Message; + string msg = ExtractDetail(ex); if(string.IsNullOrWhiteSpace(msg)) return $"{status} on {endpoint} (no response body)"; if(msg.Length > 400) msg = msg.Substring(0, 400) + "…"; return $"{status} on {endpoint} — {msg}"; } + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } + public async Task SkipAsync(long id) { try { await client.OldDos.Pending[(int)id].Skip.PostAsync(); return true; } diff --git a/Marechai/Services/ResolutionsService.cs b/Marechai/Services/ResolutionsService.cs index e0953036..abc4319f 100644 --- a/Marechai/Services/ResolutionsService.cs +++ b/Marechai/Services/ResolutionsService.cs @@ -69,7 +69,7 @@ public class ResolutionsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -87,7 +87,7 @@ public class ResolutionsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -105,11 +105,28 @@ public class ResolutionsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { return (false, ex.Message); } } + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/ScreensService.cs b/Marechai/Services/ScreensService.cs index 15e9b642..6980c0a0 100644 --- a/Marechai/Services/ScreensService.cs +++ b/Marechai/Services/ScreensService.cs @@ -69,7 +69,7 @@ public class ScreensService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -87,7 +87,7 @@ public class ScreensService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -105,7 +105,7 @@ public class ScreensService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -151,7 +151,7 @@ public class ScreensService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -169,11 +169,28 @@ public class ScreensService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { return (false, ex.Message); } } + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/SoftwareFamiliesService.cs b/Marechai/Services/SoftwareFamiliesService.cs index eecb9b93..938fef39 100644 --- a/Marechai/Services/SoftwareFamiliesService.cs +++ b/Marechai/Services/SoftwareFamiliesService.cs @@ -69,7 +69,7 @@ public class SoftwareFamiliesService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -87,7 +87,7 @@ public class SoftwareFamiliesService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -105,7 +105,7 @@ public class SoftwareFamiliesService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -140,7 +140,7 @@ public class SoftwareFamiliesService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -158,7 +158,7 @@ public class SoftwareFamiliesService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -195,4 +195,21 @@ public class SoftwareFamiliesService(Marechai.ApiClient.Client client) return []; } } + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/SoftwarePlatformsService.cs b/Marechai/Services/SoftwarePlatformsService.cs index c46a1574..64d315e6 100644 --- a/Marechai/Services/SoftwarePlatformsService.cs +++ b/Marechai/Services/SoftwarePlatformsService.cs @@ -69,7 +69,7 @@ public class SoftwarePlatformsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -87,7 +87,7 @@ public class SoftwarePlatformsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -105,11 +105,28 @@ public class SoftwarePlatformsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { return (false, ex.Message); } } + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/SoftwareVersionsService.cs b/Marechai/Services/SoftwareVersionsService.cs index 2827421d..2dd0e779 100644 --- a/Marechai/Services/SoftwareVersionsService.cs +++ b/Marechai/Services/SoftwareVersionsService.cs @@ -83,7 +83,7 @@ public class SoftwareVersionsService(Marechai.ApiClient.Client client, Reference } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -101,7 +101,7 @@ public class SoftwareVersionsService(Marechai.ApiClient.Client client, Reference } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -119,7 +119,7 @@ public class SoftwareVersionsService(Marechai.ApiClient.Client client, Reference } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -154,7 +154,7 @@ public class SoftwareVersionsService(Marechai.ApiClient.Client client, Reference } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractDetail(ex)); } catch(Exception ex) { @@ -172,7 +172,7 @@ public class SoftwareVersionsService(Marechai.ApiClient.Client client, Reference } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractDetail(ex)); } catch(Exception ex) { @@ -211,4 +211,21 @@ public class SoftwareVersionsService(Marechai.ApiClient.Client client, Reference } public Task> GetAllLicensesAsync() => referenceData.GetLicensesAsync(); + + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } } diff --git a/Marechai/Services/SoundSynthsService.cs b/Marechai/Services/SoundSynthsService.cs index 58fef803..b7b69b83 100644 --- a/Marechai/Services/SoundSynthsService.cs +++ b/Marechai/Services/SoundSynthsService.cs @@ -155,7 +155,7 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (null, ex.Message); + return (null, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -173,7 +173,7 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -191,7 +191,7 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -290,7 +290,7 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { @@ -308,7 +308,7 @@ public class SoundSynthsService(Marechai.ApiClient.Client client) } catch(ApiException ex) { - return (false, ex.Message); + return (false, ExtractErrorMessage(ex)); } catch(Exception ex) { diff --git a/Marechai/Services/WwpcImportsService.cs b/Marechai/Services/WwpcImportsService.cs index ac7da07c..50a35d71 100644 --- a/Marechai/Services/WwpcImportsService.cs +++ b/Marechai/Services/WwpcImportsService.cs @@ -142,19 +142,37 @@ public sealed class WwpcImportsService(Client client, ILogger /// Build a human-readable diagnostic from a Kiota . Includes the HTTP - /// status code plus, when present, a short slice of ex.Message (which usually carries the - /// server's response body) so the admin dialog shows more than just HTTP 404. When the - /// message is empty the endpoint path is appended so it's clear which call failed. + /// status code plus, when present, a short slice of the extracted ProblemDetails detail / title + /// (which is the human-friendly text the server intended to surface) so the admin dialog shows + /// more than just HTTP 404. When the message is empty the endpoint path is appended so + /// it's clear which call failed. /// static string BuildErrorMessage(string endpoint, ApiException ex) { string status = ex.ResponseStatusCode > 0 ? $"HTTP {ex.ResponseStatusCode}" : "HTTP error"; - string msg = ex.Message; + string msg = ExtractDetail(ex); if(string.IsNullOrWhiteSpace(msg)) return $"{status} on {endpoint} (no response body)"; if(msg.Length > 400) msg = msg.Substring(0, 400) + "…"; return $"{status} on {endpoint} — {msg}"; } + static string ExtractDetail(ApiException ex) + { + // Kiota maps server error responses to a typed ProblemDetails (which inherits from + // ApiException). The base Exception.Message just returns "Exception of type 'X' was + // thrown." — the real, user-facing text lives on Detail / Title. Surface those when + // present, falling back to Message only if the server gave us nothing useful. + if(ex is ProblemDetails pd) + { + if(!string.IsNullOrWhiteSpace(pd.Detail)) return pd.Detail; + if(!string.IsNullOrWhiteSpace(pd.Title)) return pd.Title; + } + + if(ex is { ResponseStatusCode: 0 } || string.IsNullOrWhiteSpace(ex.Message)) return "Unknown error"; + + return ex.Message; + } + public async Task SkipAsync(long id) { try { await client.Wwpc.Pending[(int)id].Skip.PostAsync(); return true; }