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
45 changes: 44 additions & 1 deletion src/CodeClash.API/Controllers/Contests/ContestController.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using CodeClash.Application.Contests.CreateContest;
using CodeClash.Application.Contests.AddContestProblems;
using CodeClash.Application.Contests.CreateContest;
using CodeClash.Application.Contests.GetContest;
using CodeClash.Application.Contests.RegisterInContest;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
Expand Down Expand Up @@ -38,4 +40,45 @@ public async Task<IActionResult> CreateContest(
result)
: BadRequest(result);
}

[Authorize]
[HttpPost("{contestId:guid}/register")]
public async Task<IActionResult> RegisterInContest(
Guid contestId,
CancellationToken cancellationToken)
{
var result = await sender.Send(new RegisterInContestCommand(contestId), cancellationToken);
return result.IsSuccess
? Ok(result)
: BadRequest(result);
}

[HttpPost("{contestId:guid}/problems/{problemId:guid}")]
[Authorize]
public async Task<IActionResult> AddProblem(
Guid contestId,
Guid problemId,
CancellationToken cancellationToken)
{
var result = await sender.Send(
new AddContestProblemCommand(contestId, problemId),
cancellationToken);

if (result.IsSuccess)
{
return Ok(result);
}

return result.Error!.Code switch
{
"Auth.Unauthorized" => Unauthorized(result),
"Contest.NotFound" => NotFound(result),
"Contest.Forbidden" => Forbid(),
"Contest.Locked" => BadRequest(result),
"Contest.ProblemLimitReached" => BadRequest(result),
"Problem.NotFound" => NotFound(result),
"Contest.DuplicateProblem" => Conflict(result),
_ => BadRequest(result)
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public async Task<IActionResult> GetProblemSubmissions(Guid problemId)

return response.IsSuccess
? Ok(response.Value)
: response.Error.Code switch
: response.Error!.Code switch
{
"Auth.Error" => Forbid(),
_ => NotFound()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CodeClash.Domain.Models.Identity;

namespace CodeClash.Application.Abstractions.CurrentUser;
public interface ICurrentUserService
{
string? IdentityId { get; }
Task<User?> GetUserAsync();
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public async Task<Result<AccessTokenDto>> Handle(

if (confirmResult.IsFailure)
{
return Result.Failure<AccessTokenDto>(confirmResult.Error);
return Result.Failure<AccessTokenDto>(confirmResult.Error!);
}

var email = confirmResult.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public async Task<Result<RegisterResponseDto>> Handle(

if (identityResult.IsFailure)
{
throw new Exception(identityResult.Error.Message);
throw new Exception(identityResult.Error!.Message);
}

var identityId = identityResult.Value;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using CodeClash.Application.Abstractions.Messaging;

namespace CodeClash.Application.Contests.AddContestProblems;
public sealed record AddContestProblemCommand(
Guid ContestId,
Guid ProblemId) : ICommand<AddContestProblemResponse>;

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using CodeClash.Application.Abstractions.CurrentUser;
using CodeClash.Application.Abstractions.Messaging;
using CodeClash.Domain.Abstractions;
using CodeClash.Domain.Premitives;

namespace CodeClash.Application.Contests.AddContestProblems;
internal sealed class AddContestProblemCommandHandler(
IContestRepository contestRepository,
IProblemRepository problemRepository,
ICurrentUserService currentUserService,
IUnitOfWork unitOfWork) : ICommandHandler<AddContestProblemCommand, AddContestProblemResponse>
{
private const int MaxProblemsPerContest = 5;
public async Task<Result<AddContestProblemResponse>> Handle(
AddContestProblemCommand request,
CancellationToken cancellationToken)
{
var user = await currentUserService.GetUserAsync();
if (user is null)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Auth.Unauthorized", "Unauthorized"));
}

var contest = await contestRepository.GetByIdAsync(request.ContestId);
if (contest is null)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Contest.NotFound", "Contest not found"));
}

if (contest.SetterId != user.Id)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Contest.Forbidden", "Only the contest creator can add problems"));
}

if (contest.ContestStatus != ContestStatus.Upcoming)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Contest.Locked", "Problems can only be added before the contest starts"));
}

var currentProblemCount = await contestRepository.GetProblemCountAsync(request.ContestId);
if (currentProblemCount >= MaxProblemsPerContest)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Contest.ProblemLimitReached", $"A contest cannot have more than {MaxProblemsPerContest} problems"));
}
var problem = await problemRepository.GetByIdAsync(request.ProblemId);
if (problem is null)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Problem.NotFound", "Problem not found"));
}

var alreadyExists = await contestRepository.HasProblemAsync(request.ContestId, request.ProblemId);
if (alreadyExists)
{
return Result.Failure<AddContestProblemResponse>(
new Error("Contest.DuplicateProblem", "This problem is already in the contest"));
}

await contestRepository.AddProblemAsync(request.ContestId, request.ProblemId);
await unitOfWork.SaveChangesAsync(cancellationToken);

var response = new AddContestProblemResponse(problem.Id, contest.Id, problem.Name);

return Result.Success(response, "Problem added to contest successfully");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace CodeClash.Application.Contests.AddContestProblems;
public record AddContestProblemResponse(
Guid ContestId,
Guid ProblemId,
string ProblemName);
Original file line number Diff line number Diff line change
@@ -1,26 +1,25 @@
using System.Security.Claims;
using CodeClash.Application.Abstractions.CurrentUser;
using CodeClash.Application.Abstractions.Messaging;
using CodeClash.Application.Mapping;
using CodeClash.Domain.Abstractions;
using CodeClash.Domain.Premitives;
using Microsoft.AspNetCore.Http;

namespace CodeClash.Application.Contests.CreateContest;
internal sealed class CreateContestCommandHandler(
IContestRepository contestRepository,
IUnitOfWork unitOfWork,
IHttpContextAccessor contextAccessor)
ICurrentUserService currentUserService,
IUnitOfWork unitOfWork)
: ICommandHandler<CreateContestCommand, CreateContestResponse>
{
public async Task<Result<CreateContestResponse>> Handle(
CreateContestCommand request,
CancellationToken cancellationToken)
{
var userId = contextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier);

if (userId is null)
var user = await currentUserService.GetUserAsync();
if (user is null)
{
return Result.Failure<CreateContestResponse>(new Error("Auth.Error", "Unauthorized"));
return Result.Failure<CreateContestResponse>(
new Error("Auth.Unauthorized", "Unauthorized"));
}

if (request.StartTime >= request.EndTime)
Expand All @@ -29,7 +28,7 @@ public async Task<Result<CreateContestResponse>> Handle(
new Error("Contest.InvalidDates", "Start time must be before end time"));
}

var contest = request.ToContest(userId);
var contest = request.ToContest(user.Id);

contestRepository.Add(contest);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,95 +1,88 @@
using System.Globalization;
using System.Text;
using CodeClash.Application.Abstractions.Cache;
using CodeClash.Application.Abstractions.Cache;
using CodeClash.Application.Abstractions.CurrentUser;
using CodeClash.Application.Abstractions.Messaging;
using CodeClash.Application.Mapping;
using CodeClash.Domain.Abstractions;
using CodeClash.Domain.Premitives;
using CodeClash.Domain.Premitives.Responses;
using Microsoft.AspNetCore.Http;

namespace CodeClash.Application.Contests.GetContest;
internal sealed class GetContestQueryHandler(
IResponseCacheService cacheService,
IHttpContextAccessor httpContext,
IContestRepository contestRepository)
IContestRepository contestRepository,
ICurrentUserService currentUserService,
IUserContestRepository userContestRepository)
: IQueryHandler<GetContestQuery, IReadOnlyList<ContestProblemResponse>>
{
private const int CacheDurationHours = 2;

public async Task<Result<IReadOnlyList<ContestProblemResponse>>> Handle(
GetContestQuery request,
CancellationToken cancellationToken)
{
var user = await currentUserService.GetUserAsync();
if (user is null)
{
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(
new Error("Auth.Unauthorized", "Unauthorized"));
}

var contest = await contestRepository.GetByIdAsync(request.Id);

if (contest is null)
{
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(new Error("Contest.Not.Found", "Not Found!"));
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(
new Error("Contest.NotFound", "Contest not found"));
}

if (contest.ContestStatus == ContestStatus.Upcoming)
var isRegisterd = await userContestRepository.IsRegistered(request.Id, user.Id);

if (!isRegisterd)
{
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(new Error("Contest.Not.Started", "Not Started yet!"));
return Result.Failure<IReadOnlyList<ContestProblemResponse>>(
new Error("Contest.NotRegistered", "You are not registered in this contest"));
}

if (contest.ContestStatus == ContestStatus.Running)
{
string cacheKey = GenerateCacheKeyFromRequest();

// check cache
var cachedData = await cacheService.GetCachedResponseAsync(cacheKey);

// cache hit → return cached data
if (cachedData is not null)
{
var serializedData = Helper.DeserializeCollection<ContestProblemResponse>(cachedData);

return Result.Success<IReadOnlyList<ContestProblemResponse>>(
serializedData.ToList(),
"Contest Problems fetched successfully");
}

// cache miss → get from db, cache it, return it
var problems =
await contestRepository.GetContestProblemsByIdAsync(request.Id);
return await HandleRunningContestAsync(request.Id);
}

var mappedResponse = problems
.Select(p => p.ToContestProblemResponse())
.ToList();
return await FetchProblemsAsync(request.Id);
}

await cacheService.CacheResponseAsync(cacheKey, mappedResponse, TimeSpan.FromHours(2));
private async Task<Result<IReadOnlyList<ContestProblemResponse>>> HandleRunningContestAsync(
Guid contestId)
{
string cacheKey = GenerateCacheKey(contestId);

var cachedData = await cacheService.GetCachedResponseAsync(cacheKey);
if (cachedData is not null)
{
var serialized = Helper.DeserializeCollection<ContestProblemResponse>(cachedData);
return Result.Success<IReadOnlyList<ContestProblemResponse>>(
mappedResponse, "Contest Problems fetched successfully");
serialized.ToList(), "Contest Problems fetched successfully");
}

// past → get from db directly, no caching
var dbProblems = await contestRepository.GetContestProblemsByIdAsync(request.Id);
var response = dbProblems
.Select(p => p.ToContestProblemResponse())
.ToList();
var problems = await contestRepository.GetContestProblemsByIdAsync(contestId);
var mapped = problems.Select(p => p.ToContestProblemResponse()).ToList();

await cacheService.CacheResponseAsync(cacheKey, mapped, TimeSpan.FromHours(CacheDurationHours));

return Result.Success<IReadOnlyList<ContestProblemResponse>>(
response, "Contest Problems Feteched Successfully");
mapped, "Contest Problems fetched successfully");
}

private string GenerateCacheKeyFromRequest()
private async Task<Result<IReadOnlyList<ContestProblemResponse>>> FetchProblemsAsync(
Guid contestId)
{
// key : unique for each request so generate it from request
// generate it from URL Path + Query String
var request = httpContext.HttpContext.Request;
var keyBuilder = new StringBuilder();

keyBuilder.Append(request.Path);

// Ordered by key to handle cases when the order of query string
// parameters changes but the values remain the same.
// use InvariantCulture to avoid locale-dependent formatting warning
var problems = await contestRepository.GetContestProblemsByIdAsync(contestId);
var mapped = problems.Select(p => p.ToContestProblemResponse()).ToList();

foreach (var (key, value) in request.Query.OrderBy(x => x.Key))
{
keyBuilder.Append(CultureInfo.InvariantCulture, $"|{key}-{value}");
}

return keyBuilder.ToString();
return Result.Success<IReadOnlyList<ContestProblemResponse>>(
mapped, "Contest Problems fetched successfully");
}

private static string GenerateCacheKey(Guid contestId) =>
$"contest-problems:{contestId}";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using CodeClash.Application.Abstractions.Messaging;

namespace CodeClash.Application.Contests.RegisterInContest;
public record RegisterInContestCommand(
Guid Id) : ICommand<RegisterInContestResponse>;
Loading
Loading