Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions apps/api/Controllers/AdminQuestionsController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -22,11 +24,13 @@ public AdminQuestionsController(AppDbContext context)
}

[HttpGet]
public async Task<ActionResult<List<QuestionBankDto>>> GetQuestions(
public async Task<ActionResult<PagedResult<QuestionBankDto>>> 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();

Expand All @@ -39,8 +43,9 @@ public async Task<ActionResult<List<QuestionBankDto>>> 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,
Expand All @@ -52,22 +57,37 @@ public async Task<ActionResult<List<QuestionBankDto>>> GetQuestions(
q.CreatedBy,
q.CreatedAt
))
.ToListAsync();
.ToPagedAsync(page, pageSize);

return Ok(items);
return Ok(pagedResult);
}

[HttpPost]
public async Task<ActionResult<QuestionBankDto>> 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,
Expand Down
70 changes: 46 additions & 24 deletions apps/api/Controllers/InterviewsController.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,6 +37,36 @@ public InterviewsController(
[HttpPost]
public async Task<ActionResult<CreateInterviewResponseDto>> 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<int>("DailyInterviewLimit", 10);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
Expand Down Expand Up @@ -74,7 +106,7 @@ public async Task<ActionResult<CreateInterviewResponseDto>> 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();

Expand All @@ -89,9 +121,9 @@ public async Task<ActionResult<CreateInterviewResponseDto>> 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),
Expand All @@ -110,34 +142,24 @@ public async Task<ActionResult<CreateInterviewResponseDto>> CreateInterview([Fro
}

[HttpGet]
public async Task<ActionResult<List<InterviewSummaryDto>>> GetInterviews([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
public async Task<ActionResult<PagedResult<InterviewSummaryDto>>> 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}")]
Expand Down
12 changes: 9 additions & 3 deletions apps/api/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using DevDialogue.Api.Models;
using Microsoft.AspNetCore.Mvc;

namespace DevDialogue.Api.Controllers;
Expand All @@ -11,9 +12,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" }
}
});
}
}
23 changes: 23 additions & 0 deletions apps/api/Helpers/PaginationExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;

namespace DevDialogue.Api.Helpers;

public record PagedResult<T>(List<T> Items, int Page, int PageSize, int TotalCount)
{
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
}

public static class PaginationExtensions
{
public static async Task<PagedResult<T>> ToPagedAsync<T>(
this IQueryable<T> 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<T>(items, page, pageSize, total);
}
}
26 changes: 26 additions & 0 deletions apps/api/Models/Dtos/AdminDtos.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;

namespace DevDialogue.Api.Models.Dtos;

public record QuestionBankDto(
Expand All @@ -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
);
15 changes: 15 additions & 0 deletions apps/api/Models/Dtos/AnswerRequestDto.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
9 changes: 8 additions & 1 deletion apps/api/Models/Dtos/InterviewDtos.cs
Original file line number Diff line number Diff line change
@@ -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
);

Expand All @@ -14,6 +19,8 @@ string FirstQuestion
);

public record SendMessageDto(
[Required(ErrorMessage = "Повідомлення не може бути порожнім")]
[MaxLength(3000, ErrorMessage = "Відповідь перевищує ліміт у 3000 символів")]
string Content
);

Expand Down
9 changes: 9 additions & 0 deletions apps/api/Models/SupportedOptions.cs
Original file line number Diff line number Diff line change
@@ -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"];
}
Loading
Loading