From c3373f3a53024a2af5145eb2d18d25cd03e720e0 Mon Sep 17 00:00:00 2001 From: DidoraRudenko Date: Wed, 21 Oct 2026 18:54:03 +0300 Subject: [PATCH] =?UTF-8?q?fix=C3=90:=20Pagination=20and=20Validation?= =?UTF-8?q?=C2=8E=20DTO=20=D1=82=D0=B0=20=D0=BD=D0=B0=D0=BB=D0=B0=D1=88?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=B0=D0=BD=D0=BE=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/AdminQuestionsController.cs | 36 +++++++--- apps/api/Controllers/InterviewsController.cs | 70 ++++++++++++------- apps/api/Controllers/OptionsController.cs | 14 ++-- apps/api/Helpers/PaginationExtensions.cs | 23 ++++++ apps/api/Models/Dtos/AdminDtos.cs | 26 +++++++ apps/api/Models/Dtos/AnswerRequestDto.cs | 15 ++++ apps/api/Models/Dtos/InterviewDtos.cs | 9 ++- apps/api/Models/SupportedOptions.cs | 9 +++ apps/api/Program.cs | 54 +++++++++++++- apps/api/Security/RequireRoleAttribute.cs | 27 ++++++- apps/api/appsettings.Development.json | 6 +- 11 files changed, 243 insertions(+), 46 deletions(-) create mode 100644 apps/api/Helpers/PaginationExtensions.cs create mode 100644 apps/api/Models/Dtos/AnswerRequestDto.cs create mode 100644 apps/api/Models/SupportedOptions.cs diff --git a/apps/api/Controllers/AdminQuestionsController.cs b/apps/api/Controllers/AdminQuestionsController.cs index fe9a613..8287430 100644 --- a/apps/api/Controllers/AdminQuestionsController.cs +++ b/apps/api/Controllers/AdminQuestionsController.cs @@ -1,4 +1,6 @@ using DevDialogue.Api.Data; +using DevDialogue.Api.Helpers; +using DevDialogue.Api.Models; using DevDialogue.Api.Models.Dtos; using DevDialogue.Api.Models.Entities; using DevDialogue.Api.Security; @@ -22,11 +24,13 @@ public AdminQuestionsController(AppDbContext context) } [HttpGet] - public async Task>> GetQuestions( + public async Task>> GetQuestions( [FromQuery] string? direction, [FromQuery] string? level, [FromQuery] string? stage, - [FromQuery] bool? isActive) + [FromQuery] bool? isActive, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) { var query = _context.QuestionsBank.AsNoTracking().AsQueryable(); @@ -39,8 +43,9 @@ public async Task>> GetQuestions( if (isActive.HasValue) query = query.Where(q => q.IsActive == isActive.Value); - var items = await query + var pagedResult = await query .OrderByDescending(q => q.CreatedAt) + .ThenByDescending(q => q.Id) .Select(q => new QuestionBankDto( q.Id, q.Direction, @@ -52,22 +57,37 @@ public async Task>> GetQuestions( q.CreatedBy, q.CreatedAt )) - .ToListAsync(); + .ToPagedAsync(page, pageSize); - return Ok(items); + return Ok(pagedResult); } [HttpPost] public async Task> CreateQuestion([FromBody] CreateQuestionDto dto) { + if (!SupportedOptions.Directions.Contains(dto.Direction.ToLowerInvariant())) + { + return BadRequest(new { error = $"Invalid direction '{dto.Direction}'. Supported: {string.Join(", ", SupportedOptions.Directions)}", code = "INVALID_DIRECTION" }); + } + + if (!SupportedOptions.Levels.Contains(dto.Level.ToLowerInvariant())) + { + return BadRequest(new { error = $"Invalid level '{dto.Level}'. Supported: {string.Join(", ", SupportedOptions.Levels)}", code = "INVALID_LEVEL" }); + } + + if (!SupportedOptions.Stages.Contains(dto.Stage.ToLowerInvariant())) + { + return BadRequest(new { error = $"Invalid stage '{dto.Stage}'. Supported: {string.Join(", ", SupportedOptions.Stages)}", code = "INVALID_STAGE" }); + } + var userId = User.GetUserId(); var item = new QuestionBank { Id = Guid.NewGuid(), - Direction = dto.Direction, - Stage = dto.Stage, - Level = dto.Level, + Direction = dto.Direction.ToLowerInvariant(), + Stage = dto.Stage.ToLowerInvariant(), + Level = dto.Level.ToLowerInvariant(), Topic = dto.Topic, QuestionText = dto.QuestionText, IsActive = true, diff --git a/apps/api/Controllers/InterviewsController.cs b/apps/api/Controllers/InterviewsController.cs index 3c2b913..e2e8fc9 100644 --- a/apps/api/Controllers/InterviewsController.cs +++ b/apps/api/Controllers/InterviewsController.cs @@ -1,5 +1,7 @@ using System.Text.Json; using DevDialogue.Api.Data; +using DevDialogue.Api.Helpers; +using DevDialogue.Api.Models; using DevDialogue.Api.Models.Dtos; using DevDialogue.Api.Models.Entities; using DevDialogue.Api.Security; @@ -35,6 +37,36 @@ public InterviewsController( [HttpPost] public async Task> CreateInterview([FromBody] CreateInterviewDto dto) { + // Валідація мови: якщо мову не підтримується/не знайдено -> 404 Not Found + if (!SupportedOptions.Languages.Contains(dto.Language.ToLowerInvariant())) + { + return NotFound(new + { + error = $"Language '{dto.Language}' not found. Supported languages: {string.Join(", ", SupportedOptions.Languages)}", + code = "LANGUAGE_NOT_FOUND" + }); + } + + // Валідація напрямку + if (!SupportedOptions.Directions.Contains(dto.Direction.ToLowerInvariant())) + { + return BadRequest(new + { + error = $"Invalid direction '{dto.Direction}'. Supported: {string.Join(", ", SupportedOptions.Directions)}", + code = "INVALID_DIRECTION" + }); + } + + // Валідація рівня + if (!SupportedOptions.Levels.Contains(dto.Level.ToLowerInvariant())) + { + return BadRequest(new + { + error = $"Invalid level '{dto.Level}'. Supported: {string.Join(", ", SupportedOptions.Levels)}", + code = "INVALID_LEVEL" + }); + } + var userId = User.GetUserId(); var dailyLimit = _config.GetValue("DailyInterviewLimit", 10); var today = DateOnly.FromDateTime(DateTime.UtcNow); @@ -74,7 +106,7 @@ public async Task> CreateInterview([Fro // Lookup starting topic from QuestionBank if available var seedQuestion = await _context.QuestionsBank .AsNoTracking() - .Where(q => q.Direction == dto.Direction && q.Level == dto.Level && q.IsActive) + .Where(q => q.Direction == dto.Direction.ToLowerInvariant() && q.Level == dto.Level.ToLowerInvariant() && q.IsActive) .OrderBy(_ => EF.Functions.Random()) .FirstOrDefaultAsync(); @@ -89,9 +121,9 @@ public async Task> CreateInterview([Fro { Id = Guid.NewGuid(), UserId = userId, - Direction = dto.Direction, - Level = dto.Level, - Language = dto.Language, + Direction = dto.Direction.ToLowerInvariant(), + Level = dto.Level.ToLowerInvariant(), + Language = dto.Language.ToLowerInvariant(), Stage = "hard_skills", Status = "active", Transcript = JsonSerializer.Serialize(initialTranscript), @@ -110,34 +142,24 @@ public async Task> CreateInterview([Fro } [HttpGet] - public async Task>> GetInterviews([FromQuery] int page = 1, [FromQuery] int pageSize = 20) + public async Task>> GetInterviews([FromQuery] int page = 1, [FromQuery] int pageSize = 20) { var userId = User.GetUserId(); var query = _context.Interviews .AsNoTracking() .Where(i => i.UserId == userId) - .OrderByDescending(i => i.CreatedAt); - - var items = await query - .Skip((page - 1) * pageSize) - .Take(pageSize) + .OrderByDescending(i => i.CreatedAt) + .ThenByDescending(i => i.Id) .Select(i => new InterviewSummaryDto( - i.Id, - i.UserId, - i.Direction, - i.Level, - i.Language, - i.Stage, - i.Status, - i.Score, - i.QuestionCount, - i.CreatedAt, - i.UpdatedAt - )) - .ToListAsync(); + i.Id, i.UserId, i.Direction, i.Level, i.Language, + i.Stage, i.Status, i.Score, i.QuestionCount, + i.CreatedAt, i.UpdatedAt + )); + + var pagedResult = await query.ToPagedAsync(page, pageSize); - return Ok(items); + return Ok(pagedResult); } [HttpGet("{id:guid}")] diff --git a/apps/api/Controllers/OptionsController.cs b/apps/api/Controllers/OptionsController.cs index 9349488..2af9252 100644 --- a/apps/api/Controllers/OptionsController.cs +++ b/apps/api/Controllers/OptionsController.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Mvc; +using DevDialogue.Api.Models; +using Microsoft.AspNetCore.Mvc; namespace DevDialogue.Api.Controllers; @@ -12,9 +13,14 @@ public IActionResult GetOptions() // Повертаємо доступні опції для створення співбесіди return Ok(new { - directions = new[] { "frontend", "backend", "devops", "qa", "pm" }, - levels = new[] { "junior", "middle", "senior" }, - languages = new[] { "uk", "en" } + directions = SupportedOptions.Directions, + levels = SupportedOptions.Levels, + stages = SupportedOptions.Stages, + languages = new[] + { + new { id = "uk", code = "uk", name = "Українська" }, + new { id = "en", code = "en", name = "English" } + } }); } } \ No newline at end of file diff --git a/apps/api/Helpers/PaginationExtensions.cs b/apps/api/Helpers/PaginationExtensions.cs new file mode 100644 index 0000000..3897c92 --- /dev/null +++ b/apps/api/Helpers/PaginationExtensions.cs @@ -0,0 +1,23 @@ +using Microsoft.EntityFrameworkCore; + +namespace DevDialogue.Api.Helpers; + +public record PagedResult(List Items, int Page, int PageSize, int TotalCount) +{ + public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize); +} + +public static class PaginationExtensions +{ + public static async Task> ToPagedAsync( + this IQueryable query, int page, int pageSize) + { + page = Math.Max(page, 1); + pageSize = Math.Clamp(pageSize, 1, 100); + + var total = await query.CountAsync(); + var items = await query.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); + + return new PagedResult(items, page, pageSize, total); + } +} \ No newline at end of file diff --git a/apps/api/Models/Dtos/AdminDtos.cs b/apps/api/Models/Dtos/AdminDtos.cs index 0905c05..ceb6fcf 100644 --- a/apps/api/Models/Dtos/AdminDtos.cs +++ b/apps/api/Models/Dtos/AdminDtos.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations; + namespace DevDialogue.Api.Models.Dtos; public record QuestionBankDto( @@ -13,18 +15,42 @@ DateTime CreatedAt ); public record CreateQuestionDto( + [Required(ErrorMessage = "Напрямок (Direction) є обов'язковим")] + [MaxLength(50, ErrorMessage = "Напрямок не може перевищувати 50 символів")] string Direction, + + [Required(ErrorMessage = "Етап (Stage) є обов'язковим")] + [MaxLength(50, ErrorMessage = "Етап не може перевищувати 50 символів")] string Stage, + + [Required(ErrorMessage = "Рівень (Level) є обов'язковим")] + [MaxLength(50, ErrorMessage = "Рівень не може перевищувати 50 символів")] string Level, + + [Required(ErrorMessage = "Тема (Topic) є обов'язковою")] + [MaxLength(200, ErrorMessage = "Тема не може перевищувати 200 символів")] string Topic, + + [Required(ErrorMessage = "Текст питання (QuestionText) є обов'язковим")] + [MaxLength(3000, ErrorMessage = "Текст питання не може перевищувати 3000 символів")] string QuestionText ); public record UpdateQuestionDto( + [MaxLength(50, ErrorMessage = "Напрямок не може перевищувати 50 символів")] string? Direction, + + [MaxLength(50, ErrorMessage = "Етап не може перевищувати 50 символів")] string? Stage, + + [MaxLength(50, ErrorMessage = "Рівень не може перевищувати 50 символів")] string? Level, + + [MaxLength(200, ErrorMessage = "Тема не може перевищувати 200 символів")] string? Topic, + + [MaxLength(3000, ErrorMessage = "Текст питання не може перевищувати 3000 символів")] string? QuestionText, + bool? IsActive ); diff --git a/apps/api/Models/Dtos/AnswerRequestDto.cs b/apps/api/Models/Dtos/AnswerRequestDto.cs new file mode 100644 index 0000000..f3d9a7f --- /dev/null +++ b/apps/api/Models/Dtos/AnswerRequestDto.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.EntityFrameworkCore; + +namespace DevDialogue.Api.Models.Dtos +{ + public class AnswerRequestDto + { + [Required(ErrorMessage = "Відповідь не може бути порожньою")] + [MaxLength(3000, ErrorMessage = "Відповідь перевищує ліміт у 3000 символів")] + public string UserAnswer { get; set; } = string.Empty; + + [Required] + public Guid QuestionId { get; set; } + } +} diff --git a/apps/api/Models/Dtos/InterviewDtos.cs b/apps/api/Models/Dtos/InterviewDtos.cs index 6f3d61a..1325db0 100644 --- a/apps/api/Models/Dtos/InterviewDtos.cs +++ b/apps/api/Models/Dtos/InterviewDtos.cs @@ -1,10 +1,15 @@ using System.Text.Json.Serialization; - +using System.ComponentModel.DataAnnotations; namespace DevDialogue.Api.Models.Dtos; public record CreateInterviewDto( + [Required(ErrorMessage = "Напрямок (Direction) є обов'язковим")] string Direction, + + [Required(ErrorMessage = "Рівень (Level) є обов'язковим")] string Level, + + [Required(ErrorMessage = "Мова (Language) є обов'язковою")] string Language ); @@ -14,6 +19,8 @@ string FirstQuestion ); public record SendMessageDto( + [Required(ErrorMessage = "Повідомлення не може бути порожнім")] + [MaxLength(3000, ErrorMessage = "Відповідь перевищує ліміт у 3000 символів")] string Content ); diff --git a/apps/api/Models/SupportedOptions.cs b/apps/api/Models/SupportedOptions.cs new file mode 100644 index 0000000..5939bad --- /dev/null +++ b/apps/api/Models/SupportedOptions.cs @@ -0,0 +1,9 @@ +namespace DevDialogue.Api.Models; + +public static class SupportedOptions +{ + public static readonly string[] Directions = ["frontend", "backend", "devops", "qa", "pm"]; + public static readonly string[] Levels = ["junior", "middle", "senior"]; + public static readonly string[] Stages = ["hard_skills", "soft_skills", "english"]; + public static readonly string[] Languages = ["uk", "en"]; +} diff --git a/apps/api/Program.cs b/apps/api/Program.cs index 170a12b..547ccb0 100644 --- a/apps/api/Program.cs +++ b/apps/api/Program.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Security.Claims; using DevDialogue.Api.Data; using DevDialogue.Api.Models.Entities; using DevDialogue.Api.Services.AI; @@ -111,6 +112,39 @@ ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; + + if (builder.Environment.IsDevelopment()) + { + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var authHeader = context.Request.Headers.Authorization.ToString(); + // Дозволяємо тестування в Postman без живого Supabase токена + if (string.IsNullOrEmpty(authHeader) || + authHeader.StartsWith("Bearer dev", StringComparison.OrdinalIgnoreCase) || + authHeader.StartsWith("Bearer test", StringComparison.OrdinalIgnoreCase) || + authHeader.Contains("mock-dev-jwt-token") || + authHeader.Contains("DYX1I2")) + { + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, "00000000-0000-0000-0000-000000000001"), + new Claim("sub", "00000000-0000-0000-0000-000000000001"), + new Claim(ClaimTypes.Email, "demo@devdialogue.io"), + new Claim("email", "demo@devdialogue.io"), + new Claim(ClaimTypes.Role, "admin"), + new Claim("role", "admin"), + new Claim("aud", "authenticated") + }; + var identity = new ClaimsIdentity(claims, "DevMock"); + context.Principal = new ClaimsPrincipal(identity); + context.Success(); + } + return Task.CompletedTask; + } + }; + } }); builder.Services.AddAuthorization(); @@ -129,8 +163,7 @@ .SetIsOriginAllowed(origin => { var host = new Uri(origin).Host; - return host == "localhost" || host.EndsWith(".vercel.app"); - }) + return host == "localhost" || host.EndsWith(".vercel.app") || host.EndsWith(".onrender.com"); }) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); @@ -140,11 +173,26 @@ // Controllers & Swagger builder.Services.AddControllers(); +// Перевизначення формату помилок валідації (ModelState) // Перевизначення формату помилок валідації (ModelState) builder.Services.Configure(options => { options.InvalidModelStateResponseFactory = context => - new BadRequestObjectResult(new { error = "Validation failed", code = "VALIDATION_ERROR" }); + { + var errors = context.ModelState + .Where(e => e.Value?.Errors.Count > 0) + .ToDictionary( + kvp => kvp.Key, + kvp => kvp.Value!.Errors.Select(er => er.ErrorMessage).ToArray() + ); + + return new BadRequestObjectResult(new + { + error = "Validation failed", + code = "VALIDATION_ERROR", + errors + }); + }; }); builder.Services.AddEndpointsApiExplorer(); diff --git a/apps/api/Security/RequireRoleAttribute.cs b/apps/api/Security/RequireRoleAttribute.cs index f8cc487..89f8fe8 100644 --- a/apps/api/Security/RequireRoleAttribute.cs +++ b/apps/api/Security/RequireRoleAttribute.cs @@ -30,7 +30,14 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext context) var user = context.HttpContext.User; if (user.Identity?.IsAuthenticated != true) { - context.Result = new UnauthorizedResult(); + context.Result = new ObjectResult(new + { + error = "Authentication required.", + code = "UNAUTHORIZED" + }) + { + StatusCode = StatusCodes.Status401Unauthorized + }; return; } @@ -43,12 +50,26 @@ public async Task OnAuthorizationAsync(AuthorizationFilterContext context) if (dbUser == null || !string.Equals(dbUser.Role, _requiredRole, StringComparison.OrdinalIgnoreCase)) { - context.Result = new ForbidResult(); + context.Result = new ObjectResult(new + { + error = $"Access denied: '{_requiredRole}' role is required.", + code = "FORBIDDEN" + }) + { + StatusCode = StatusCodes.Status403Forbidden + }; } } catch { - context.Result = new UnauthorizedResult(); + context.Result = new ObjectResult(new + { + error = "Authentication required.", + code = "UNAUTHORIZED" + }) + { + StatusCode = StatusCodes.Status401Unauthorized + }; } } } diff --git a/apps/api/appsettings.Development.json b/apps/api/appsettings.Development.json index 794c123..4b3d207 100644 --- a/apps/api/appsettings.Development.json +++ b/apps/api/appsettings.Development.json @@ -6,10 +6,10 @@ } }, "Supabase": { - "Url": "https://your-project.supabase.co", - "JwtSecret": "your-local-dev-secret-here" + "Url": "https://lzvjmonlkryljwkanexr.supabase.co", + "JwtSecret": "u1D7dsv84EZdNuGJz7j5FBg7e/7vyNJW53/K9G+ZFz9vPR/McZj7Iv0EDNi/+aJ2qdE3sLI+RnGCqYAoVwjCuw==" }, "ConnectionStrings": { "DefaultConnection": "" } -} +} \ No newline at end of file