Обновлены контесты и группы
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m34s

This commit is contained in:
2025-11-04 15:13:51 +03:00
parent 593dc7cb0c
commit b84eb61627
22 changed files with 3860 additions and 240 deletions

View File

@@ -10,11 +10,14 @@ public record CreateContestRequest(
[Required] [StringLength(128, MinimumLength = 3)] string Name,
string? Description,
ContestScheduleType ScheduleType,
ContestVisibility Visibility,
DateTime? StartsAt,
DateTime? EndsAt,
DateTime? AvailableFrom,
DateTime? AvailableUntil,
int? AttemptDurationMinutes,
int? MaxAttempts,
bool? AllowEarlyFinish,
int? GroupId,
IEnumerable<int>? MissionIds,
IEnumerable<int>? ArticleIds,

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using LiquidCode.Infrastructure.Database.Entities;
namespace LiquidCode.Api.Contests.Requests;
@@ -9,11 +10,17 @@ public record UpdateContestRequest(
string? Name,
string? Description,
ContestScheduleType? ScheduleType,
ContestVisibility? Visibility,
DateTime? StartsAt,
DateTime? EndsAt,
DateTime? AvailableFrom,
DateTime? AvailableUntil,
int? AttemptDurationMinutes,
int? MaxAttempts,
bool? AllowEarlyFinish,
int? GroupId,
IEnumerable<int>? MissionIds,
IEnumerable<int>? ArticleIds
IEnumerable<int>? ArticleIds,
IEnumerable<int>? ParticipantIds,
IEnumerable<int>? OrganizerIds
);

View File

@@ -7,10 +7,15 @@ namespace LiquidCode.Api.Contests.Responses;
/// Ответ с информацией о попытке пользователя в контесте
/// </summary>
public record ContestAttemptResponse(
int AttemptId,
int ContestId,
int UserId,
int AttemptIndex,
ContestAttemptStatus Status,
ContestScheduleType ScheduleType,
DateTime StartedAt,
DateTime ExpiresAt,
int AttemptCount
DateTime? ExpiresAt,
DateTime? FinishedAt,
decimal TotalScore,
int SolvedCount
);

View File

@@ -12,11 +12,14 @@ public record ContestResponse(
string Name,
string? Description,
ContestScheduleType ScheduleType,
ContestVisibility Visibility,
DateTime? StartsAt,
DateTime? EndsAt,
DateTime? AvailableFrom,
DateTime? AvailableUntil,
int? AttemptDurationMinutes,
int? MaxAttempts,
bool AllowEarlyFinish,
int? GroupId,
string? GroupName,
IReadOnlyList<ContestMissionResponse> Missions,
@@ -29,11 +32,14 @@ public record ContestResponse(
entity.Name,
entity.Description,
entity.ScheduleType,
entity.Visibility,
entity.StartsAt,
entity.EndsAt,
entity.AvailableFrom,
entity.AvailableUntil,
entity.AttemptDurationMinutes,
entity.MaxAttempts,
entity.AllowEarlyFinish,
entity.GroupId,
entity.Group?.Name,
entity.Missions

View File

@@ -73,7 +73,8 @@ public class GroupsController(IGroupService groupService) : ControllerBase
[HttpGet("{id:int}")]
public async Task<IActionResult> Get([FromRoute] int id, CancellationToken cancellationToken)
{
var result = await groupService.GetAsync(id, cancellationToken);
var requesterId = User.TryGetUserId(out var userId) ? userId : (int?)null;
var result = await groupService.GetAsync(id, requesterId, cancellationToken);
if (result == null)
return NotFound("Group not found.");
@@ -105,7 +106,7 @@ public class GroupsController(IGroupService groupService) : ControllerBase
/// </summary>
[Authorize]
[HttpPost("{id:int}/members")]
public async Task<IActionResult> UpsertMember([FromRoute] int id, [FromBody] GroupMembershipRequest request, CancellationToken cancellationToken)
public async Task<IActionResult> UpdateMemberRole([FromRoute] int id, [FromBody] GroupMembershipRequest request, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
@@ -113,7 +114,7 @@ public class GroupsController(IGroupService groupService) : ControllerBase
if (!ModelState.IsValid)
return BadRequest(ModelState);
var success = await groupService.UpsertMemberAsync(id, userId, request.UserId, request.Role, cancellationToken);
var success = await groupService.UpdateMemberRoleAsync(id, userId, request.UserId, request.Role, cancellationToken);
if (!success)
return NotFound("Group not found or access denied.");
@@ -136,4 +137,109 @@ public class GroupsController(IGroupService groupService) : ControllerBase
return NoContent();
}
/// <summary>
/// Обновляет токен присоединения к группе
/// </summary>
[Authorize]
[HttpPost("{id:int}/join-token/rotate")]
public async Task<IActionResult> RotateJoinToken([FromRoute] int id, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
var result = await groupService.RotateJoinLinkAsync(id, userId, cancellationToken);
if (result == null)
return NotFound("Group not found or access denied.");
return Ok(result);
}
/// <summary>
/// Создает приглашение в группу
/// </summary>
[Authorize]
[HttpPost("{id:int}/invitations")]
public async Task<IActionResult> CreateInvitation([FromRoute] int id, [FromBody] CreateGroupInvitationRequest request, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
if (!ModelState.IsValid)
return BadRequest(ModelState);
var result = await groupService.CreateInvitationAsync(id, userId, request, cancellationToken);
if (result == null)
return NotFound("Group not found, access denied or user already invited.");
return Ok(result);
}
/// <summary>
/// Получает список активных приглашений в группе
/// </summary>
[Authorize]
[HttpGet("{id:int}/invitations")]
public async Task<IActionResult> GetInvitations([FromRoute] int id, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
var invitations = await groupService.GetPendingInvitationsAsync(id, userId, cancellationToken);
return Ok(invitations);
}
/// <summary>
/// Отменяет приглашение в группу
/// </summary>
[Authorize]
[HttpDelete("{id:int}/invitations/{invitationId:int}")]
public async Task<IActionResult> CancelInvitation([FromRoute] int id, [FromRoute] int invitationId, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
var success = await groupService.CancelInvitationAsync(id, userId, invitationId, cancellationToken);
if (!success)
return NotFound("Invitation not found or access denied.");
return NoContent();
}
/// <summary>
/// Пользователь отвечает на приглашение по токену
/// </summary>
[Authorize]
[HttpPost("invitations/{token}/respond")]
public async Task<IActionResult> RespondInvitation([FromRoute] string token, [FromBody] RespondGroupInvitationRequest request, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
if (!ModelState.IsValid)
return BadRequest(ModelState);
var success = await groupService.RespondToInvitationAsync(token, userId, request.Accept, cancellationToken);
if (!success)
return BadRequest("Invitation cannot be processed.");
return NoContent();
}
/// <summary>
/// Присоединение к группе по приглашению-ссылке
/// </summary>
[Authorize]
[HttpPost("join/{token}")]
public async Task<IActionResult> JoinByToken([FromRoute] string token, CancellationToken cancellationToken)
{
if (!User.TryGetUserId(out var userId))
return Unauthorized("User ID not found in claims.");
var response = await groupService.JoinByTokenAsync(token, userId, cancellationToken);
if (response == null)
return BadRequest("Join token is invalid or expired.");
return Ok(response);
}
}

View File

@@ -0,0 +1,13 @@
using System.ComponentModel.DataAnnotations;
using LiquidCode.Infrastructure.Database.Entities;
namespace LiquidCode.Api.Groups.Requests;
/// <summary>
/// Запрос на создание приглашения в группу
/// </summary>
public record CreateGroupInvitationRequest(
[Required]
string Target,
GroupInvitationDeliveryChannel DeliveryChannel
);

View File

@@ -0,0 +1,11 @@
using System.ComponentModel.DataAnnotations;
namespace LiquidCode.Api.Groups.Requests;
/// <summary>
/// Запрос на ответ по приглашению в группу
/// </summary>
public record RespondGroupInvitationRequest(
[Required]
bool Accept
);

View File

@@ -13,17 +13,32 @@ public record GroupResponse(
string Name,
string? Description,
IReadOnlyList<GroupMemberResponse> Members,
IReadOnlyList<GroupContestSummary> Contests
IReadOnlyList<GroupContestSummary> Contests,
GroupJoinLinkResponse? ActiveJoinLink,
IReadOnlyList<GroupInvitationResponse> PendingInvitations
)
{
public static GroupResponse FromEntity(DbGroup entity) => new(
entity.Id,
entity.Name,
entity.Description,
entity.Memberships
.Select(m => new GroupMemberResponse(m.UserId, m.User.Username, m.Role))
.ToList(),
entity.Contests
public static GroupResponse FromEntity(
DbGroup entity,
bool includePrivateDetails = false,
DbGroupJoinToken? activeJoinToken = null,
IEnumerable<DbGroupInvitation>? invitations = null)
{
var now = DateTime.UtcNow;
var members = entity.Memberships
.Select(m => new GroupMemberResponse(
m.UserId,
m.User.Username,
m.Role,
m.JoinedAt,
m.IsAutoJoined))
.OrderByDescending(m => m.Role.HasFlag(GroupMembershipRole.Creator))
.ThenByDescending(m => m.Role.HasFlag(GroupMembershipRole.Administrator))
.ThenBy(m => m.JoinedAt)
.ToList();
var contests = entity.Contests
.Where(c => !c.IsDeleted)
.OrderByDescending(c => c.ScheduleType == ContestScheduleType.FixedWindow ? c.StartsAt : c.AvailableFrom)
.ThenByDescending(c => c.Id)
@@ -31,19 +46,51 @@ public record GroupResponse(
c.Id,
c.Name,
c.ScheduleType,
c.Visibility,
c.StartsAt,
c.EndsAt,
c.AvailableFrom,
c.AvailableUntil,
c.AttemptDurationMinutes))
.ToList()
);
c.AttemptDurationMinutes,
c.MaxAttempts,
c.AllowEarlyFinish))
.ToList();
GroupJoinLinkResponse? joinLink = null;
if (includePrivateDetails && activeJoinToken != null && activeJoinToken.RevokedAt == null && activeJoinToken.ExpiresAt > now)
{
joinLink = new GroupJoinLinkResponse(activeJoinToken.Token, activeJoinToken.ExpiresAt);
}
var pendingInvitations = includePrivateDetails && invitations != null
? invitations
.Where(i => i.Status == GroupInvitationStatus.Pending && i.ExpiresAt > now && i.RevokedAt == null)
.Select(i => new GroupInvitationResponse(
i.Id,
i.InviteeId,
i.Invitee.Username,
i.Status,
i.ExpiresAt,
i.CreatedAt))
.OrderByDescending(i => i.CreatedAt)
.ToList()
: new List<GroupInvitationResponse>();
return new GroupResponse(
entity.Id,
entity.Name,
entity.Description,
members,
contests,
joinLink,
pendingInvitations);
}
}
/// <summary>
/// Информация об участнике группы
/// </summary>
public record GroupMemberResponse(int UserId, string Username, GroupMembershipRole Role);
public record GroupMemberResponse(int UserId, string Username, GroupMembershipRole Role, DateTime JoinedAt, bool IsAutoJoined);
/// <summary>
/// Краткое описание контеста, созданного в группе
@@ -52,9 +99,29 @@ public record GroupContestSummary(
int ContestId,
string Name,
ContestScheduleType ScheduleType,
ContestVisibility Visibility,
DateTime? StartsAt,
DateTime? EndsAt,
DateTime? AvailableFrom,
DateTime? AvailableUntil,
int? AttemptDurationMinutes
int? AttemptDurationMinutes,
int? MaxAttempts,
bool AllowEarlyFinish
);
/// <summary>
/// Активный токен присоединения к группе
/// </summary>
public record GroupJoinLinkResponse(string Token, DateTime ExpiresAt);
/// <summary>
/// Приглашение в группу
/// </summary>
public record GroupInvitationResponse(
int InvitationId,
int InviteeId,
string InviteeUsername,
GroupInvitationStatus Status,
DateTime ExpiresAt,
DateTime CreatedAt
);

View File

@@ -6,12 +6,12 @@ namespace LiquidCode.Domain.Enums;
public enum StatementFormat
{
/// <summary>
/// Формат LaTeX (каталоги вида statements/<language>)
/// Формат LaTeX (каталоги вида statements/&lt;language&gt;)
/// </summary>
Latex = 0,
/// <summary>
/// HTML представление (каталоги вида statements/.html/<language>)
/// HTML представление (каталоги вида statements/.html/&lt;language&gt;)
/// </summary>
Html = 1
}

View File

@@ -1,3 +1,4 @@
using System;
using LiquidCode.Infrastructure.Database.Entities;
namespace LiquidCode.Domain.Interfaces.Repositories;
@@ -24,7 +25,17 @@ public interface IContestRepository : IRepository<DbContest>
Task SyncMissionsAsync(DbContest contest, IEnumerable<int> missionIds, CancellationToken cancellationToken = default);
Task SyncArticlesAsync(DbContest contest, IEnumerable<int> articleIds, CancellationToken cancellationToken = default);
Task UpsertMembershipAsync(int contestId, int userId, ContestMembershipRole role, CancellationToken cancellationToken = default);
Task UpsertMembershipAsync(int contestId, int userId, ContestMembershipRole role, ContestMembershipOptions? options, CancellationToken cancellationToken = default);
Task RemoveMembershipAsync(int contestId, int userId, CancellationToken cancellationToken = default);
Task<DbContestMembership?> GetMembershipAsync(int contestId, int userId, CancellationToken cancellationToken = default);
Task<DbContestAttempt?> FindActiveAttemptAsync(int contestId, int userId, CancellationToken cancellationToken = default);
Task AddAttemptAsync(DbContestAttempt attempt, CancellationToken cancellationToken = default);
Task UpdateAttemptAsync(DbContestAttempt attempt, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DbContestAttemptMissionResult>> GetAttemptResultsAsync(int attemptId, CancellationToken cancellationToken = default);
}
public record ContestMembershipOptions(
bool IsAutoJoined = false,
int? InvitationId = null,
DateTime? JoinedAt = null
);

View File

@@ -1,3 +1,4 @@
using System;
using LiquidCode.Infrastructure.Database.Entities;
namespace LiquidCode.Domain.Interfaces.Repositories;
@@ -8,6 +9,7 @@ namespace LiquidCode.Domain.Interfaces.Repositories;
public interface IGroupRepository : IRepository<DbGroup>
{
Task<DbGroup?> FindWithDetailsAsync(int id, CancellationToken cancellationToken = default);
Task<DbGroup?> FindWithDetailsAsync(int id, bool includeSoftDeleted, CancellationToken cancellationToken = default);
Task<(IEnumerable<DbGroup> Items, bool HasNextPage)> GetForUserAsync(
int userId,
@@ -15,6 +17,22 @@ public interface IGroupRepository : IRepository<DbGroup>
int pageNumber,
CancellationToken cancellationToken = default);
Task UpsertMembershipAsync(int groupId, int userId, GroupMembershipRole role, CancellationToken cancellationToken = default);
Task<DbGroupMembership?> GetMembershipAsync(int groupId, int userId, CancellationToken cancellationToken = default);
Task UpsertMembershipAsync(int groupId, int userId, GroupMembershipRole role, GroupMembershipOptions? options, CancellationToken cancellationToken = default);
Task RemoveMembershipAsync(int groupId, int userId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<DbGroupInvitation>> GetActiveInvitationsAsync(int groupId, CancellationToken cancellationToken = default);
Task<DbGroupInvitation?> GetInvitationByIdAsync(int groupId, int invitationId, CancellationToken cancellationToken = default);
Task<DbGroupInvitation?> GetInvitationByTokenAsync(string token, CancellationToken cancellationToken = default);
Task<DbGroupInvitation> AddInvitationAsync(DbGroupInvitation invitation, CancellationToken cancellationToken = default);
Task SaveInvitationAsync(DbGroupInvitation invitation, CancellationToken cancellationToken = default);
Task<DbGroupJoinToken?> GetActiveJoinTokenAsync(int groupId, CancellationToken cancellationToken = default);
Task<DbGroupJoinToken?> GetJoinTokenByValueAsync(string token, CancellationToken cancellationToken = default);
Task<DbGroupJoinToken> RotateJoinTokenAsync(int groupId, int createdById, TimeSpan ttl, CancellationToken cancellationToken = default);
}
public record GroupMembershipOptions(
int? InvitedById = null,
int? InvitationId = null,
bool IsAutoJoined = false,
DateTime? JoinedAt = null
);

View File

@@ -12,6 +12,11 @@ public interface IUserRepository : IRepository<DbUser>
/// </summary>
Task<DbUser?> FindByUsernameAsync(string username, CancellationToken cancellationToken = default);
/// <summary>
/// Находит пользователя по адресу электронной почты
/// </summary>
Task<DbUser?> FindByEmailAsync(string email, CancellationToken cancellationToken = default);
/// <summary>
/// Проверяет, существует ли пользователь с данным именем пользователя
/// </summary>

View File

@@ -12,8 +12,14 @@ public interface IGroupService
Task<GroupResponse?> CreateAsync(CreateGroupRequest request, int ownerId, CancellationToken cancellationToken = default);
Task<GroupResponse?> UpdateAsync(int groupId, UpdateGroupRequest request, int requesterId, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(int groupId, int requesterId, CancellationToken cancellationToken = default);
Task<GroupResponse?> GetAsync(int groupId, CancellationToken cancellationToken = default);
Task<GroupResponse?> GetAsync(int groupId, int? requesterId, CancellationToken cancellationToken = default);
Task<GroupsPageResponse?> GetForUserAsync(int userId, int pageSize, int pageNumber, CancellationToken cancellationToken = default);
Task<bool> UpsertMemberAsync(int groupId, int requesterId, int targetUserId, GroupMembershipRole role, CancellationToken cancellationToken = default);
Task<bool> UpdateMemberRoleAsync(int groupId, int requesterId, int targetUserId, GroupMembershipRole role, CancellationToken cancellationToken = default);
Task<bool> RemoveMemberAsync(int groupId, int requesterId, int targetUserId, CancellationToken cancellationToken = default);
Task<GroupJoinLinkResponse?> RotateJoinLinkAsync(int groupId, int requesterId, CancellationToken cancellationToken = default);
Task<GroupInvitationResponse?> CreateInvitationAsync(int groupId, int requesterId, CreateGroupInvitationRequest request, CancellationToken cancellationToken = default);
Task<IReadOnlyList<GroupInvitationResponse>> GetPendingInvitationsAsync(int groupId, int requesterId, CancellationToken cancellationToken = default);
Task<bool> CancelInvitationAsync(int groupId, int requesterId, int invitationId, CancellationToken cancellationToken = default);
Task<bool> RespondToInvitationAsync(string token, int userId, bool accept, CancellationToken cancellationToken = default);
Task<GroupResponse?> JoinByTokenAsync(string token, int userId, CancellationToken cancellationToken = default);
}

View File

@@ -54,13 +54,18 @@ public class ContestService : IContestService
return null;
}
var creator = await _userRepository.FindByIdAsync(creatorId, cancellationToken);
if (creator == null)
return null;
var now = DateTime.UtcNow;
var visibility = request.Visibility;
DbGroup? group = null;
if (request.GroupId.HasValue)
if (visibility == ContestVisibility.GroupPrivate)
{
if (!request.GroupId.HasValue)
{
_logger.LogWarning("GroupPrivate contest requires group id");
return null;
}
group = await _groupRepository.FindWithDetailsAsync(request.GroupId.Value, cancellationToken);
if (group == null)
{
@@ -68,10 +73,9 @@ public class ContestService : IContestService
return null;
}
var membership = group.Memberships.FirstOrDefault(m => m.UserId == creatorId);
if (membership == null || !membership.Role.HasFlag(GroupMembershipRole.Administrator))
if (!IsGroupAdmin(group, creatorId))
{
_logger.LogWarning("User {UserId} is not admin in group {GroupId}", creatorId, group.Id);
_logger.LogWarning("User {UserId} is not admin in group {GroupId}", creatorId, request.GroupId.Value);
return null;
}
}
@@ -81,21 +85,35 @@ public class ContestService : IContestService
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
ScheduleType = schedule.ScheduleType,
Visibility = visibility,
StartsAt = schedule.StartsAt,
EndsAt = schedule.EndsAt,
AvailableFrom = schedule.AvailableFrom,
AvailableUntil = schedule.AvailableUntil,
AttemptDurationMinutes = schedule.AttemptDurationMinutes,
GroupId = request.GroupId,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
MaxAttempts = NormalizeMaxAttempts(request.MaxAttempts) ?? 1,
AllowEarlyFinish = request.AllowEarlyFinish ?? true,
GroupId = visibility == ContestVisibility.GroupPrivate ? request.GroupId : null,
CreatedAt = now,
UpdatedAt = now
};
await _contestRepository.CreateAsync(contest, cancellationToken);
await _contestRepository.UpsertMembershipAsync(contest.Id, creatorId, ContestMembershipRole.Organizer, cancellationToken);
await _contestRepository.UpsertMembershipAsync(
contest.Id,
creatorId,
ContestMembershipRole.Organizer,
new ContestMembershipOptions(JoinedAt: now),
cancellationToken);
if (visibility == ContestVisibility.GroupPrivate && group != null)
{
await AutoEnrollGroupAsync(contest.Id, group, now, cancellationToken);
}
await SyncLineupAsync(contest, request.MissionIds, request.ArticleIds, cancellationToken);
await SyncMembersAsync(contest.Id, request.ParticipantIds, ContestMembershipRole.Participant, cancellationToken);
await SyncMembersAsync(contest.Id, request.ParticipantIds, ContestMembershipRole.Participant, cancellationToken, skipUserId: creatorId);
await SyncMembersAsync(contest.Id, request.OrganizerIds, ContestMembershipRole.Organizer, cancellationToken, skipUserId: creatorId);
var full = await _contestRepository.FindWithDetailsAsync(contest.Id, cancellationToken);
@@ -111,11 +129,33 @@ public class ContestService : IContestService
if (!IsOrganizer(contest, requesterId))
return null;
if (!string.IsNullOrWhiteSpace(request.Name))
contest.Name = request.Name.Trim();
var newVisibility = request.Visibility ?? contest.Visibility;
var now = DateTime.UtcNow;
DbGroup? targetGroup = null;
int? newGroupId = contest.GroupId;
if (request.Description != null)
contest.Description = request.Description.Trim();
if (newVisibility == ContestVisibility.GroupPrivate)
{
var groupId = request.GroupId ?? contest.GroupId;
if (!groupId.HasValue)
{
_logger.LogWarning("GroupPrivate contest requires group id on update");
return null;
}
targetGroup = await _groupRepository.FindWithDetailsAsync(groupId.Value, cancellationToken);
if (targetGroup == null)
return null;
if (!IsGroupAdmin(targetGroup, requesterId))
return null;
newGroupId = groupId;
}
else
{
newGroupId = null;
}
var targetScheduleType = request.ScheduleType ?? contest.ScheduleType;
var candidateStartsAt = request.StartsAt ?? contest.StartsAt;
@@ -138,10 +178,11 @@ public class ContestService : IContestService
return null;
}
var previousScheduleType = contest.ScheduleType;
var previousAvailableFrom = contest.AvailableFrom;
var previousAvailableUntil = contest.AvailableUntil;
var previousAttemptDuration = contest.AttemptDurationMinutes;
if (!string.IsNullOrWhiteSpace(request.Name))
contest.Name = request.Name.Trim();
if (request.Description != null)
contest.Description = request.Description.Trim();
contest.ScheduleType = schedule.ScheduleType;
contest.StartsAt = schedule.StartsAt;
@@ -149,20 +190,22 @@ public class ContestService : IContestService
contest.AvailableFrom = schedule.AvailableFrom;
contest.AvailableUntil = schedule.AvailableUntil;
contest.AttemptDurationMinutes = schedule.AttemptDurationMinutes;
if (previousScheduleType != schedule.ScheduleType ||
(schedule.ScheduleType == ContestScheduleType.FlexibleWindow &&
(previousAvailableFrom != schedule.AvailableFrom ||
previousAvailableUntil != schedule.AvailableUntil ||
previousAttemptDuration != schedule.AttemptDurationMinutes)))
contest.Visibility = newVisibility;
contest.GroupId = newGroupId;
if (request.MaxAttempts.HasValue)
{
ResetFlexibleAttempts(contest);
contest.MaxAttempts = NormalizeMaxAttempts(request.MaxAttempts);
}
contest.UpdatedAt = DateTime.UtcNow;
contest.AllowEarlyFinish = request.AllowEarlyFinish ?? contest.AllowEarlyFinish;
contest.UpdatedAt = now;
await _contestRepository.UpdateAsync(contest, cancellationToken);
if (newVisibility == ContestVisibility.GroupPrivate && targetGroup != null)
{
await AutoEnrollGroupAsync(contest.Id, targetGroup, now, cancellationToken);
}
await SyncLineupAsync(contest, request.MissionIds, request.ArticleIds, cancellationToken);
var updated = await _contestRepository.FindWithDetailsAsync(contest.Id, cancellationToken);
@@ -194,7 +237,12 @@ public class ContestService : IContestService
return null;
var (contests, hasNext) = await _contestRepository.GetUpcomingAsync(pageSize, pageNumber, DateTime.UtcNow, cancellationToken);
return new ContestsPageResponse(hasNext, contests.Select(ContestResponse.FromEntity));
var filtered = contests
.Where(c => c.Visibility == ContestVisibility.Public)
.Select(ContestResponse.FromEntity)
.ToList();
return new ContestsPageResponse(hasNext, filtered);
}
public async Task<ContestsPageResponse?> GetForGroupAsync(int groupId, int pageSize, int pageNumber, CancellationToken cancellationToken = default)
@@ -203,7 +251,8 @@ public class ContestService : IContestService
return null;
var (contests, hasNext) = await _contestRepository.GetByGroupAsync(groupId, pageSize, pageNumber, cancellationToken);
return new ContestsPageResponse(hasNext, contests.Select(ContestResponse.FromEntity));
var responses = contests.Select(ContestResponse.FromEntity).ToList();
return new ContestsPageResponse(hasNext, responses);
}
public async Task<bool> UpsertMemberAsync(int contestId, int requesterId, int targetUserId, ContestMembershipRole role, CancellationToken cancellationToken = default)
@@ -215,7 +264,20 @@ public class ContestService : IContestService
if (!IsOrganizer(contest, requesterId))
return false;
await _contestRepository.UpsertMembershipAsync(contestId, targetUserId, role, cancellationToken);
if (await _userRepository.FindByIdAsync(targetUserId, cancellationToken) == null)
{
_logger.LogWarning("User {UserId} not found while adding to contest {ContestId}", targetUserId, contestId);
return false;
}
var now = DateTime.UtcNow;
await _contestRepository.UpsertMembershipAsync(
contestId,
targetUserId,
role,
new ContestMembershipOptions(JoinedAt: now),
cancellationToken);
return true;
}
@@ -228,6 +290,19 @@ public class ContestService : IContestService
if (!IsOrganizer(contest, requesterId))
return false;
var membership = contest.Memberships.FirstOrDefault(m => m.UserId == targetUserId);
if (membership == null)
return false;
if (membership.Role.HasFlag(ContestMembershipRole.Organizer) && targetUserId == requesterId)
{
if (contest.Memberships.Count(m => m.Role.HasFlag(ContestMembershipRole.Organizer)) <= 1)
{
_logger.LogWarning("Cannot remove the last organizer from contest {ContestId}", contestId);
return false;
}
}
await _contestRepository.RemoveMembershipAsync(contestId, targetUserId, cancellationToken);
return true;
}
@@ -238,154 +313,118 @@ public class ContestService : IContestService
if (contest == null || contest.IsDeleted)
return null;
if (contest.ScheduleType != ContestScheduleType.FlexibleWindow)
{
_logger.LogWarning("Attempt start requested for contest {ContestId} with schedule type {ScheduleType}", contestId, contest.ScheduleType);
return null;
}
if (!contest.AvailableFrom.HasValue || !contest.AvailableUntil.HasValue || !contest.AttemptDurationMinutes.HasValue)
{
_logger.LogWarning("Contest {ContestId} has inconsistent flexible schedule configuration", contestId);
return null;
}
var now = DateTime.UtcNow;
var membership = contest.Memberships.FirstOrDefault(m => m.UserId == userId);
if (membership == null)
{
_logger.LogWarning("User {UserId} is not enrolled in contest {ContestId}", userId, contestId);
return null;
if (contest.Visibility != ContestVisibility.Public)
{
_logger.LogWarning("User {UserId} is not allowed to join contest {ContestId}", userId, contestId);
return null;
}
await _contestRepository.UpsertMembershipAsync(
contestId,
userId,
ContestMembershipRole.Participant,
new ContestMembershipOptions(IsAutoJoined: true, JoinedAt: now),
cancellationToken);
contest = await _contestRepository.FindWithDetailsAsync(contestId, cancellationToken);
membership = contest?.Memberships.FirstOrDefault(m => m.UserId == userId);
if (membership == null)
return null;
}
var now = DateTime.UtcNow;
if (contest == null)
return null;
var isOrganizer = membership.Role.HasFlag(ContestMembershipRole.Organizer);
if (!isOrganizer && (now < contest.AvailableFrom.Value || now > contest.AvailableUntil.Value))
var missions = contest.Missions;
if (missions == null || missions.Count == 0)
{
_logger.LogWarning("Contest {ContestId} is not available for starting attempt by user {UserId}", contestId, userId);
_logger.LogWarning("Contest {ContestId} has no missions configured", contestId);
return null;
}
if (membership.ActiveAttemptStartedAt.HasValue &&
membership.ActiveAttemptExpiresAt.HasValue &&
now <= membership.ActiveAttemptExpiresAt.Value)
membership = contest.Memberships.First(m => m.UserId == userId);
var activeAttempt = membership.ActiveAttempt;
if (activeAttempt != null && activeAttempt.Status == ContestAttemptStatus.Active)
{
return new ContestAttemptResponse(
contest.Id,
userId,
contest.ScheduleType,
membership.ActiveAttemptStartedAt.Value,
membership.ActiveAttemptExpiresAt.Value,
membership.AttemptCount);
if (activeAttempt.ExpiresAt.HasValue && now > activeAttempt.ExpiresAt.Value)
{
activeAttempt.Status = ContestAttemptStatus.Expired;
activeAttempt.FinishedAt = activeAttempt.ExpiresAt;
activeAttempt.FinishedBy = ContestAttemptFinishReason.Timer;
membership.ActiveAttemptId = null;
membership.ActiveAttempt = null;
await _contestRepository.UpdateAttemptAsync(activeAttempt, cancellationToken);
await _contestRepository.SaveChangesAsync(cancellationToken);
}
else
{
return ToAttemptResponse(activeAttempt, contest.ScheduleType);
}
}
var expireAt = now.AddMinutes(contest.AttemptDurationMinutes.Value);
if (contest.AvailableUntil.Value < expireAt)
if (contest.MaxAttempts.HasValue)
{
expireAt = contest.AvailableUntil.Value;
var attemptsCount = membership.Attempts.Count;
if (attemptsCount >= contest.MaxAttempts.Value)
{
_logger.LogWarning("User {UserId} reached max attempts for contest {ContestId}", userId, contestId);
return null;
}
}
if (expireAt <= now)
{
_logger.LogWarning("Calculated attempt window is invalid for contest {ContestId} and user {UserId}", contestId, userId);
if (!IsContestAccessibleForStart(contest, now, isOrganizer))
return null;
}
membership.ActiveAttemptStartedAt = now;
membership.ActiveAttemptExpiresAt = expireAt;
membership.AttemptCount += 1;
membership.UpdatedAt = DateTime.UtcNow;
var expireAt = CalculateAttemptExpiration(contest, now);
if (expireAt != null && expireAt <= now)
return null;
var attemptIndex = membership.Attempts.Count + 1;
var attempt = new DbContestAttempt
{
ContestId = contest.Id,
UserId = userId,
AttemptIndex = attemptIndex,
Status = ContestAttemptStatus.Active,
StartedAt = now,
ExpiresAt = expireAt,
Membership = membership
};
await _contestRepository.AddAttemptAsync(attempt, cancellationToken);
membership.ActiveAttemptId = attempt.Id;
membership.ActiveAttempt = attempt;
membership.LastAttemptStartedAt = now;
membership.Attempts.Add(attempt);
await _contestRepository.SaveChangesAsync(cancellationToken);
return new ContestAttemptResponse(
contest.Id,
userId,
contest.ScheduleType,
membership.ActiveAttemptStartedAt.Value,
membership.ActiveAttemptExpiresAt.Value,
membership.AttemptCount);
return ToAttemptResponse(attempt, contest.ScheduleType);
}
private static void ResetFlexibleAttempts(DbContest contest)
private async Task AutoEnrollGroupAsync(int contestId, DbGroup group, DateTime joinedAt, CancellationToken cancellationToken)
{
foreach (var membership in contest.Memberships)
foreach (var member in group.Memberships)
{
membership.ActiveAttemptStartedAt = null;
membership.ActiveAttemptExpiresAt = null;
membership.UpdatedAt = DateTime.UtcNow;
}
}
var role = member.Role.HasFlag(GroupMembershipRole.Administrator)
? ContestMembershipRole.Organizer
: ContestMembershipRole.Participant;
private static bool TryBuildSchedule(
ContestScheduleType scheduleType,
DateTime? startsAt,
DateTime? endsAt,
DateTime? availableFrom,
DateTime? availableUntil,
int? attemptDurationMinutes,
out ContestScheduleData schedule,
out string? error)
{
schedule = default!;
error = null;
switch (scheduleType)
{
case ContestScheduleType.FixedWindow when !startsAt.HasValue || !endsAt.HasValue:
error = "Для фиксированного контеста необходимо указать время начала и окончания.";
return false;
case ContestScheduleType.FixedWindow when startsAt!.Value >= endsAt!.Value:
error = "Время начала должно быть раньше времени окончания.";
return false;
case ContestScheduleType.FixedWindow:
schedule = new ContestScheduleData(
scheduleType,
startsAt.Value,
endsAt.Value,
null,
null,
null);
return true;
case ContestScheduleType.FlexibleWindow when !availableFrom.HasValue || !availableUntil.HasValue:
error = "Для гибкого контеста необходимо указать окно доступности.";
return false;
case ContestScheduleType.FlexibleWindow when !attemptDurationMinutes.HasValue:
error = "Не указана длительность попытки.";
return false;
case ContestScheduleType.FlexibleWindow when availableFrom!.Value >= availableUntil!.Value:
error = "Начало окна должно быть раньше окончания.";
return false;
case ContestScheduleType.FlexibleWindow when attemptDurationMinutes!.Value <= 0:
error = "Длительность попытки должна быть положительной.";
return false;
case ContestScheduleType.FlexibleWindow:
var totalWindowMinutes = (int)(availableUntil.Value - availableFrom.Value).TotalMinutes;
if (totalWindowMinutes <= 0)
{
error = "Окно доступности слишком короткое.";
return false;
}
if (attemptDurationMinutes.Value > totalWindowMinutes)
{
error = "Длительность попытки не может превышать окно доступности.";
return false;
}
schedule = new ContestScheduleData(
scheduleType,
null,
null,
availableFrom.Value,
availableUntil.Value,
attemptDurationMinutes.Value);
return true;
default:
error = $"Неизвестный тип расписания: {scheduleType}";
return false;
await _contestRepository.UpsertMembershipAsync(
contestId,
member.UserId,
role,
new ContestMembershipOptions(IsAutoJoined: true, JoinedAt: member.JoinedAt <= DateTime.MinValue ? joinedAt : member.JoinedAt),
cancellationToken);
}
}
@@ -445,14 +484,22 @@ public class ContestService : IContestService
if (userIds == null)
return;
var distinct = userIds
.Where(id => id != skipUserId)
.Distinct()
.ToList();
foreach (var userId in distinct)
var now = DateTime.UtcNow;
foreach (var userId in userIds.Where(id => id != skipUserId).Distinct())
{
await _contestRepository.UpsertMembershipAsync(contestId, userId, role, cancellationToken);
if (await _userRepository.FindByIdAsync(userId, cancellationToken) != null)
{
await _contestRepository.UpsertMembershipAsync(
contestId,
userId,
role,
new ContestMembershipOptions(JoinedAt: now),
cancellationToken);
}
else
{
_logger.LogWarning("User {UserId} not found while syncing contest members", userId);
}
}
}
@@ -462,6 +509,145 @@ public class ContestService : IContestService
return membership != null && membership.Role.HasFlag(ContestMembershipRole.Organizer);
}
private static bool IsGroupAdmin(DbGroup group, int userId)
{
var membership = group.Memberships.FirstOrDefault(m => m.UserId == userId);
return membership != null && membership.Role.HasFlag(GroupMembershipRole.Administrator);
}
private static bool TryBuildSchedule(
ContestScheduleType scheduleType,
DateTime? startsAt,
DateTime? endsAt,
DateTime? availableFrom,
DateTime? availableUntil,
int? attemptDurationMinutes,
out ContestScheduleData schedule,
out string? error)
{
schedule = default!;
error = null;
switch (scheduleType)
{
case ContestScheduleType.AlwaysOpen:
if (!attemptDurationMinutes.HasValue || attemptDurationMinutes.Value <= 0)
{
error = "AlwaysOpen contests require positive attempt duration.";
return false;
}
schedule = new ContestScheduleData(scheduleType, null, null, null, null, attemptDurationMinutes.Value);
return true;
case ContestScheduleType.FixedWindow:
if (!startsAt.HasValue || !endsAt.HasValue)
{
error = "FixedWindow contests require start and end time.";
return false;
}
if (startsAt.Value >= endsAt.Value)
{
error = "Contest start must be before end.";
return false;
}
schedule = new ContestScheduleData(scheduleType, startsAt.Value, endsAt.Value, null, null, attemptDurationMinutes);
return true;
case ContestScheduleType.RollingWindow:
if (!availableFrom.HasValue || !availableUntil.HasValue)
{
error = "RollingWindow contests require availability window.";
return false;
}
if (!attemptDurationMinutes.HasValue || attemptDurationMinutes.Value <= 0)
{
error = "RollingWindow contests require positive attempt duration.";
return false;
}
if (availableFrom.Value >= availableUntil.Value)
{
error = "Availability window start must be before end.";
return false;
}
var totalWindowMinutes = (int)(availableUntil.Value - availableFrom.Value).TotalMinutes;
if (attemptDurationMinutes.Value > totalWindowMinutes)
{
error = "Attempt duration cannot exceed availability window.";
return false;
}
schedule = new ContestScheduleData(scheduleType, null, null, availableFrom.Value, availableUntil.Value, attemptDurationMinutes.Value);
return true;
default:
error = $"Unsupported schedule type {scheduleType}";
return false;
}
}
private static int? NormalizeMaxAttempts(int? maxAttempts) =>
maxAttempts.HasValue && maxAttempts.Value > 0 ? maxAttempts : null;
private static bool IsContestAccessibleForStart(DbContest contest, DateTime now, bool isOrganizer)
{
switch (contest.ScheduleType)
{
case ContestScheduleType.AlwaysOpen:
return true;
case ContestScheduleType.FixedWindow:
if (!contest.StartsAt.HasValue || !contest.EndsAt.HasValue)
return false;
return isOrganizer || (now >= contest.StartsAt.Value && now <= contest.EndsAt.Value);
case ContestScheduleType.RollingWindow:
if (!contest.AvailableFrom.HasValue || !contest.AvailableUntil.HasValue)
return false;
return isOrganizer || (now >= contest.AvailableFrom.Value && now <= contest.AvailableUntil.Value);
default:
return false;
}
}
private static DateTime? CalculateAttemptExpiration(DbContest contest, DateTime start)
{
switch (contest.ScheduleType)
{
case ContestScheduleType.AlwaysOpen:
return contest.AttemptDurationMinutes.HasValue
? start.AddMinutes(contest.AttemptDurationMinutes.Value)
: null;
case ContestScheduleType.FixedWindow:
return contest.EndsAt;
case ContestScheduleType.RollingWindow:
if (!contest.AttemptDurationMinutes.HasValue || !contest.AvailableUntil.HasValue)
return null;
var desired = start.AddMinutes(contest.AttemptDurationMinutes.Value);
return desired <= contest.AvailableUntil.Value ? desired : contest.AvailableUntil.Value;
default:
return null;
}
}
private static ContestAttemptResponse ToAttemptResponse(DbContestAttempt attempt, ContestScheduleType scheduleType) =>
new(
attempt.Id,
attempt.ContestId,
attempt.UserId,
attempt.AttemptIndex,
attempt.Status,
scheduleType,
attempt.StartedAt,
attempt.ExpiresAt,
attempt.FinishedAt,
attempt.TotalScore,
attempt.SolvedCount);
private sealed record ContestScheduleData(
ContestScheduleType ScheduleType,
DateTime? StartsAt,

View File

@@ -6,6 +6,7 @@ using LiquidCode.Api.Groups.Responses;
using LiquidCode.Domain.Interfaces.Repositories;
using LiquidCode.Domain.Interfaces.Services;
using LiquidCode.Infrastructure.Database.Entities;
using LiquidCode.Shared.Constants;
using Microsoft.Extensions.Logging;
namespace LiquidCode.Domain.Services.Groups;
@@ -32,24 +33,37 @@ public class GroupService : IGroupService
if (owner == null)
return null;
var now = DateTime.UtcNow;
var group = new DbGroup
{
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
CreatedAt = now,
UpdatedAt = now
};
await _groupRepository.CreateAsync(group, cancellationToken);
await _groupRepository.UpsertMembershipAsync(group.Id, ownerId, GroupMembershipRole.Administrator, cancellationToken);
var full = await _groupRepository.FindWithDetailsAsync(group.Id, cancellationToken);
return full == null ? null : GroupResponse.FromEntity(full);
await _groupRepository.UpsertMembershipAsync(
group.Id,
ownerId,
GroupMembershipRole.Creator | GroupMembershipRole.Administrator,
new GroupMembershipOptions(JoinedAt: now),
cancellationToken);
var joinToken = await _groupRepository.RotateJoinTokenAsync(group.Id, ownerId, GroupInvitationDefaults.JoinTokenTtl, cancellationToken);
var full = await _groupRepository.FindWithDetailsAsync(group.Id, includeSoftDeleted: false, cancellationToken);
if (full == null)
return null;
return GroupResponse.FromEntity(full, includePrivateDetails: true, joinToken, invitations: Array.Empty<DbGroupInvitation>());
}
public async Task<GroupResponse?> UpdateAsync(int groupId, UpdateGroupRequest request, int requesterId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, cancellationToken);
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return null;
@@ -66,13 +80,18 @@ public class GroupService : IGroupService
await _groupRepository.UpdateAsync(group, cancellationToken);
var updated = await _groupRepository.FindWithDetailsAsync(group.Id, cancellationToken);
return updated == null ? null : GroupResponse.FromEntity(updated);
var joinToken = await _groupRepository.GetActiveJoinTokenAsync(groupId, cancellationToken);
var invitations = await _groupRepository.GetActiveInvitationsAsync(groupId, cancellationToken);
var updated = await _groupRepository.FindWithDetailsAsync(group.Id, includeSoftDeleted: false, cancellationToken);
return updated == null
? null
: GroupResponse.FromEntity(updated, includePrivateDetails: true, joinToken, invitations);
}
public async Task<bool> DeleteAsync(int groupId, int requesterId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, cancellationToken);
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return false;
@@ -83,10 +102,17 @@ public class GroupService : IGroupService
return true;
}
public async Task<GroupResponse?> GetAsync(int groupId, CancellationToken cancellationToken = default)
public async Task<GroupResponse?> GetAsync(int groupId, int? requesterId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, cancellationToken);
return group == null ? null : GroupResponse.FromEntity(group);
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return null;
var includePrivate = requesterId.HasValue && IsAdmin(group, requesterId.Value);
var joinToken = includePrivate ? await _groupRepository.GetActiveJoinTokenAsync(groupId, cancellationToken) : null;
var invitations = includePrivate ? await _groupRepository.GetActiveInvitationsAsync(groupId, cancellationToken) : Array.Empty<DbGroupInvitation>();
return GroupResponse.FromEntity(group, includePrivate, joinToken, invitations);
}
public async Task<GroupsPageResponse?> GetForUserAsync(int userId, int pageSize, int pageNumber, CancellationToken cancellationToken = default)
@@ -95,41 +121,273 @@ public class GroupService : IGroupService
return null;
var (groups, hasNext) = await _groupRepository.GetForUserAsync(userId, pageSize, pageNumber, cancellationToken);
return new GroupsPageResponse(hasNext, groups.Select(GroupResponse.FromEntity));
var responses = groups
.Select(g => GroupResponse.FromEntity(g, includePrivateDetails: false, activeJoinToken: null, invitations: null))
.ToList();
return new GroupsPageResponse(hasNext, responses);
}
public async Task<bool> UpsertMemberAsync(int groupId, int requesterId, int targetUserId, GroupMembershipRole role, CancellationToken cancellationToken = default)
public async Task<bool> UpdateMemberRoleAsync(int groupId, int requesterId, int targetUserId, GroupMembershipRole role, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, cancellationToken);
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return false;
if (!IsAdmin(group, requesterId))
return false;
if (await _userRepository.FindByIdAsync(targetUserId, cancellationToken) == null)
var membership = group.Memberships.FirstOrDefault(m => m.UserId == targetUserId);
if (membership == null)
return false;
if (membership.Role.HasFlag(GroupMembershipRole.Creator) && !role.HasFlag(GroupMembershipRole.Creator))
{
_logger.LogWarning("User {UserId} not found while adding to group {GroupId}", targetUserId, groupId);
_logger.LogWarning("Attempt to remove creator role from user {UserId} in group {GroupId}", targetUserId, groupId);
return false;
}
await _groupRepository.UpsertMembershipAsync(groupId, targetUserId, role, cancellationToken);
await _groupRepository.UpsertMembershipAsync(
groupId,
targetUserId,
role,
new GroupMembershipOptions(
InvitedById: membership.InvitedById,
InvitationId: membership.InvitationId,
IsAutoJoined: membership.IsAutoJoined,
JoinedAt: membership.JoinedAt),
cancellationToken);
return true;
}
public async Task<bool> RemoveMemberAsync(int groupId, int requesterId, int targetUserId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, cancellationToken);
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return false;
if (!IsAdmin(group, requesterId))
return false;
var membership = group.Memberships.FirstOrDefault(m => m.UserId == targetUserId);
if (membership == null)
return false;
if (membership.Role.HasFlag(GroupMembershipRole.Creator))
{
_logger.LogWarning("Attempt to remove creator {UserId} from group {GroupId}", targetUserId, groupId);
return false;
}
await _groupRepository.RemoveMembershipAsync(groupId, targetUserId, cancellationToken);
return true;
}
public async Task<GroupJoinLinkResponse?> RotateJoinLinkAsync(int groupId, int requesterId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return null;
if (!IsAdmin(group, requesterId))
return null;
var token = await _groupRepository.RotateJoinTokenAsync(groupId, requesterId, GroupInvitationDefaults.JoinTokenTtl, cancellationToken);
return new GroupJoinLinkResponse(token.Token, token.ExpiresAt);
}
public async Task<GroupInvitationResponse?> CreateInvitationAsync(int groupId, int requesterId, CreateGroupInvitationRequest request, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return null;
if (!IsAdmin(group, requesterId))
return null;
var invitee = await ResolveInviteeAsync(request, cancellationToken);
if (invitee == null)
{
_logger.LogWarning("Invitee not found for target {Target} via {Channel}", request.Target, request.DeliveryChannel);
return null;
}
if (group.Memberships.Any(m => m.UserId == invitee.Id))
{
_logger.LogInformation("User {UserId} already member of group {GroupId}", invitee.Id, groupId);
return null;
}
var existing = await _groupRepository.GetActiveInvitationsAsync(groupId, cancellationToken);
if (existing.Any(i => i.InviteeId == invitee.Id))
{
_logger.LogInformation("Active invitation already exists for user {UserId} in group {GroupId}", invitee.Id, groupId);
return null;
}
var now = DateTime.UtcNow;
var invitation = new DbGroupInvitation
{
GroupId = groupId,
InviterId = requesterId,
InviteeId = invitee.Id,
DeliveryChannel = request.DeliveryChannel,
ExpiresAt = now.Add(GroupInvitationDefaults.InvitationTtl),
Token = Guid.NewGuid().ToString("N"),
Status = GroupInvitationStatus.Pending,
CreatedAt = now,
UpdatedAt = now
};
var saved = await _groupRepository.AddInvitationAsync(invitation, cancellationToken);
return new GroupInvitationResponse(
saved.Id,
saved.InviteeId,
invitee.Username,
saved.Status,
saved.ExpiresAt,
saved.CreatedAt);
}
public async Task<IReadOnlyList<GroupInvitationResponse>> GetPendingInvitationsAsync(int groupId, int requesterId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null || !IsAdmin(group, requesterId))
return Array.Empty<GroupInvitationResponse>();
var invitations = await _groupRepository.GetActiveInvitationsAsync(groupId, cancellationToken);
return invitations
.Select(i => new GroupInvitationResponse(i.Id, i.InviteeId, i.Invitee.Username, i.Status, i.ExpiresAt, i.CreatedAt))
.ToList();
}
public async Task<bool> CancelInvitationAsync(int groupId, int requesterId, int invitationId, CancellationToken cancellationToken = default)
{
var group = await _groupRepository.FindWithDetailsAsync(groupId, includeSoftDeleted: false, cancellationToken);
if (group == null || !IsAdmin(group, requesterId))
return false;
var invitation = await _groupRepository.GetInvitationByIdAsync(groupId, invitationId, cancellationToken);
if (invitation == null)
return false;
if (invitation.Status != GroupInvitationStatus.Pending)
return false;
invitation.Status = GroupInvitationStatus.Revoked;
invitation.RevokedAt = DateTime.UtcNow;
invitation.UpdatedAt = DateTime.UtcNow;
await _groupRepository.SaveInvitationAsync(invitation, cancellationToken);
return true;
}
public async Task<bool> RespondToInvitationAsync(string token, int userId, bool accept, CancellationToken cancellationToken = default)
{
var invitation = await _groupRepository.GetInvitationByTokenAsync(token, cancellationToken);
if (invitation == null || invitation.InviteeId != userId)
return false;
if (invitation.Status != GroupInvitationStatus.Pending)
return false;
var now = DateTime.UtcNow;
if (invitation.ExpiresAt <= now || invitation.RevokedAt != null)
{
invitation.Status = GroupInvitationStatus.Expired;
invitation.UpdatedAt = now;
await _groupRepository.SaveInvitationAsync(invitation, cancellationToken);
return false;
}
if (invitation.Group.IsDeleted)
{
invitation.Status = GroupInvitationStatus.Revoked;
invitation.UpdatedAt = now;
await _groupRepository.SaveInvitationAsync(invitation, cancellationToken);
return false;
}
if (accept)
{
invitation.Status = GroupInvitationStatus.Accepted;
invitation.AcceptedAt = now;
await _groupRepository.UpsertMembershipAsync(
invitation.GroupId,
userId,
GroupMembershipRole.Member,
new GroupMembershipOptions(
InvitedById: invitation.InviterId,
InvitationId: invitation.Id,
JoinedAt: now),
cancellationToken);
}
else
{
invitation.Status = GroupInvitationStatus.Declined;
invitation.DeclinedAt = now;
}
invitation.UpdatedAt = now;
await _groupRepository.SaveInvitationAsync(invitation, cancellationToken);
return true;
}
public async Task<GroupResponse?> JoinByTokenAsync(string token, int userId, CancellationToken cancellationToken = default)
{
var joinToken = await _groupRepository.GetJoinTokenByValueAsync(token, cancellationToken);
if (joinToken == null)
return null;
var now = DateTime.UtcNow;
if (joinToken.RevokedAt != null || joinToken.ExpiresAt <= now)
return null;
var group = await _groupRepository.FindWithDetailsAsync(joinToken.GroupId, includeSoftDeleted: false, cancellationToken);
if (group == null)
return null;
if (group.Memberships.Any(m => m.UserId == userId))
return GroupResponse.FromEntity(group, includePrivateDetails: false, activeJoinToken: null, invitations: null);
await _groupRepository.UpsertMembershipAsync(
group.Id,
userId,
GroupMembershipRole.Member,
new GroupMembershipOptions(IsAutoJoined: true, JoinedAt: now),
cancellationToken);
joinToken.UsageCount += 1;
joinToken.UpdatedAt = now;
await _groupRepository.SaveChangesAsync(cancellationToken);
var includePrivate = IsAdmin(group, userId);
var activeToken = includePrivate ? await _groupRepository.GetActiveJoinTokenAsync(group.Id, cancellationToken) : null;
var invitations = includePrivate ? await _groupRepository.GetActiveInvitationsAsync(group.Id, cancellationToken) : Array.Empty<DbGroupInvitation>();
var refreshed = await _groupRepository.FindWithDetailsAsync(group.Id, includeSoftDeleted: false, cancellationToken);
return refreshed == null
? null
: GroupResponse.FromEntity(refreshed, includePrivate, activeToken, invitations);
}
private async Task<DbUser?> ResolveInviteeAsync(CreateGroupInvitationRequest request, CancellationToken cancellationToken)
{
switch (request.DeliveryChannel)
{
case GroupInvitationDeliveryChannel.Username:
return await _userRepository.FindByUsernameAsync(request.Target, cancellationToken);
case GroupInvitationDeliveryChannel.Email:
return await _userRepository.FindByEmailAsync(request.Target, cancellationToken);
default:
return null;
}
}
private static bool IsAdmin(DbGroup group, int userId)
{
var membership = group.Memberships.FirstOrDefault(m => m.UserId == userId);

View File

@@ -81,6 +81,7 @@ public class SubmitService : ISubmitService
}
DbContest? contest = null;
int? contestAttemptId = null;
var finalSourceType = SubmissionSourceType.Direct;
if (contestId.HasValue)
@@ -99,7 +100,6 @@ public class SubmitService : ISubmitService
}
var membership = contest.Memberships.FirstOrDefault(m => m.UserId == userId);
var isOrganizer = membership != null && membership.Role.HasFlag(ContestMembershipRole.Organizer);
if (membership == null)
{
_logger.LogWarning("User {UserId} is not enrolled in contest {ContestId}", userId, contestId);
@@ -112,7 +112,25 @@ public class SubmitService : ISubmitService
return null;
}
var isOrganizer = membership.Role.HasFlag(ContestMembershipRole.Organizer);
var now = DateTime.UtcNow;
var activeAttempt = membership.ActiveAttempt;
if (activeAttempt != null &&
activeAttempt.Status == ContestAttemptStatus.Active &&
activeAttempt.ExpiresAt.HasValue &&
now > activeAttempt.ExpiresAt.Value)
{
_logger.LogInformation(
"Active attempt expired before submission: ContestId={ContestId}, UserId={UserId}, AttemptId={AttemptId}",
contestId,
userId,
activeAttempt.Id);
await MarkAttemptExpiredAsync(membership, activeAttempt, cancellationToken);
activeAttempt = null;
}
switch (contest.ScheduleType)
{
case ContestScheduleType.FixedWindow:
@@ -128,17 +146,18 @@ public class SubmitService : ISubmitService
return null;
}
if (finalSourceType == SubmissionSourceType.Direct)
if (activeAttempt != null && activeAttempt.Status == ContestAttemptStatus.Active)
{
finalSourceType = SubmissionSourceType.Contest;
contestAttemptId = activeAttempt.Id;
}
finalSourceType = SubmissionSourceType.Contest;
break;
case ContestScheduleType.FlexibleWindow:
case ContestScheduleType.RollingWindow:
if (!contest.AvailableFrom.HasValue || !contest.AvailableUntil.HasValue || !contest.AttemptDurationMinutes.HasValue)
{
_logger.LogWarning("Contest {ContestId} has inconsistent flexible window configuration", contestId);
_logger.LogWarning("Contest {ContestId} has inconsistent rolling window configuration", contestId);
return null;
}
@@ -150,24 +169,35 @@ public class SubmitService : ISubmitService
return null;
}
if (membership.ActiveAttemptStartedAt == null || membership.ActiveAttemptExpiresAt == null)
if (activeAttempt == null || activeAttempt.Status != ContestAttemptStatus.Active)
{
_logger.LogWarning("User {UserId} did not start an attempt in contest {ContestId}", userId, contestId);
return null;
}
if (now > membership.ActiveAttemptExpiresAt.Value)
{
_logger.LogWarning("Attempt for user {UserId} in contest {ContestId} has expired", userId, contestId);
_logger.LogWarning("User {UserId} must start an attempt before submitting in contest {ContestId}", userId, contestId);
return null;
}
}
if (finalSourceType == SubmissionSourceType.Direct)
contestAttemptId = activeAttempt?.Id;
finalSourceType = SubmissionSourceType.ContestFlexibleWindow;
break;
case ContestScheduleType.AlwaysOpen:
if (!contest.AttemptDurationMinutes.HasValue)
{
finalSourceType = SubmissionSourceType.ContestFlexibleWindow;
_logger.LogWarning("Contest {ContestId} has inconsistent always-open configuration", contestId);
return null;
}
if (!isOrganizer)
{
if (activeAttempt == null || activeAttempt.Status != ContestAttemptStatus.Active)
{
_logger.LogWarning("User {UserId} must maintain an active attempt in contest {ContestId}", userId, contestId);
return null;
}
}
contestAttemptId = activeAttempt?.Id;
finalSourceType = SubmissionSourceType.ContestFlexibleWindow;
break;
default:
@@ -204,6 +234,7 @@ public class SubmitService : ISubmitService
Solution = solution,
Contest = contest,
ContestId = contest?.Id,
ContestAttemptId = contestAttemptId,
SourceType = finalSourceType
};
@@ -361,6 +392,18 @@ public class SubmitService : ISubmitService
}
}
private async Task MarkAttemptExpiredAsync(DbContestMembership membership, DbContestAttempt attempt, CancellationToken cancellationToken)
{
attempt.Status = ContestAttemptStatus.Expired;
attempt.FinishedBy = ContestAttemptFinishReason.Timer;
attempt.FinishedAt = attempt.ExpiresAt ?? DateTime.UtcNow;
membership.ActiveAttemptId = null;
membership.ActiveAttempt = null;
membership.UpdatedAt = DateTime.UtcNow;
await _contestRepository.UpdateAttemptAsync(attempt, cancellationToken);
}
private static string ComposeStatus(
TesterState state,
TesterErrorCode errorCode,

View File

@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LiquidCode.Domain.Interfaces.Repositories;
using LiquidCode.Infrastructure.Database;
using LiquidCode.Infrastructure.Database.Entities;
@@ -56,6 +59,10 @@ public class ContestRepository : IContestRepository
.ThenInclude(at => at.Tag)
.Include(c => c.Memberships)
.ThenInclude(cm => cm.User)
.Include(c => c.Memberships)
.ThenInclude(cm => cm.Attempts)
.Include(c => c.Memberships)
.ThenInclude(cm => cm.ActiveAttempt)
.FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public async Task<(IEnumerable<DbContest> Items, bool HasNextPage)> GetUpcomingAsync(
@@ -74,10 +81,16 @@ public class ContestRepository : IContestRepository
.Include(c => c.Missions).ThenInclude(cm => cm.Mission)
.Include(c => c.Articles).ThenInclude(ca => ca.Article)
.Where(c => !c.IsDeleted &&
c.Visibility == ContestVisibility.Public &&
((c.ScheduleType == ContestScheduleType.FixedWindow && c.EndsAt >= startPoint) ||
(c.ScheduleType == ContestScheduleType.FlexibleWindow && c.AvailableUntil >= startPoint)))
(c.ScheduleType == ContestScheduleType.RollingWindow && c.AvailableUntil >= startPoint) ||
c.ScheduleType == ContestScheduleType.AlwaysOpen))
.OrderBy(c => c.ScheduleType)
.ThenBy(c => c.ScheduleType == ContestScheduleType.FixedWindow ? c.StartsAt : c.AvailableFrom);
.ThenBy(c => c.ScheduleType == ContestScheduleType.FixedWindow
? c.StartsAt
: c.ScheduleType == ContestScheduleType.RollingWindow
? c.AvailableFrom
: c.CreatedAt);
var totalCount = await query.CountAsync(cancellationToken);
var hasNextPage = totalCount > pageSize * (pageNumber + 1);
@@ -102,10 +115,11 @@ public class ContestRepository : IContestRepository
var query = _dbContext.Contests
.Include(c => c.Group)
.Include(c => c.Memberships).ThenInclude(m => m.User)
.Include(c => c.Memberships).ThenInclude(m => m.ActiveAttempt)
.Include(c => c.Missions).ThenInclude(cm => cm.Mission)
.Include(c => c.Articles).ThenInclude(ca => ca.Article)
.Where(c => c.GroupId == groupId && !c.IsDeleted)
.OrderByDescending(c => c.ScheduleType == ContestScheduleType.FixedWindow ? c.StartsAt : c.AvailableFrom)
.OrderByDescending(c => c.ScheduleType == ContestScheduleType.FixedWindow ? c.StartsAt : c.AvailableFrom ?? c.CreatedAt)
.ThenByDescending(c => c.Id);
var totalCount = await query.CountAsync(cancellationToken);
@@ -205,7 +219,7 @@ public class ContestRepository : IContestRepository
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task UpsertMembershipAsync(int contestId, int userId, ContestMembershipRole role, CancellationToken cancellationToken = default)
public async Task UpsertMembershipAsync(int contestId, int userId, ContestMembershipRole role, ContestMembershipOptions? options, CancellationToken cancellationToken = default)
{
var membership = await _dbContext.ContestMemberships
.FirstOrDefaultAsync(m => m.ContestId == contestId && m.UserId == userId, cancellationToken);
@@ -216,13 +230,22 @@ public class ContestRepository : IContestRepository
{
ContestId = contestId,
UserId = userId,
Role = role
Role = role,
JoinedAt = options?.JoinedAt ?? DateTime.UtcNow,
IsAutoJoined = options?.IsAutoJoined ?? false,
InvitationId = options?.InvitationId
};
await _dbContext.ContestMemberships.AddAsync(membership, cancellationToken);
}
else
{
membership.Role = role;
membership.IsAutoJoined = options?.IsAutoJoined ?? membership.IsAutoJoined;
membership.InvitationId = options?.InvitationId ?? membership.InvitationId;
if (options?.JoinedAt != null)
{
membership.JoinedAt = options.JoinedAt.Value;
}
_dbContext.ContestMemberships.Update(membership);
}
@@ -243,5 +266,31 @@ public class ContestRepository : IContestRepository
public Task<DbContestMembership?> GetMembershipAsync(int contestId, int userId, CancellationToken cancellationToken = default) =>
_dbContext.ContestMemberships
.Include(m => m.Attempts)
.ThenInclude(a => a.MissionResults)
.Include(m => m.ActiveAttempt)
.FirstOrDefaultAsync(m => m.ContestId == contestId && m.UserId == userId, cancellationToken);
public Task<DbContestAttempt?> FindActiveAttemptAsync(int contestId, int userId, CancellationToken cancellationToken = default) =>
_dbContext.ContestAttempts
.Include(a => a.MissionResults)
.FirstOrDefaultAsync(a => a.ContestId == contestId && a.UserId == userId && a.Status == ContestAttemptStatus.Active, cancellationToken);
public async Task AddAttemptAsync(DbContestAttempt attempt, CancellationToken cancellationToken = default)
{
await _dbContext.ContestAttempts.AddAsync(attempt, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task UpdateAttemptAsync(DbContestAttempt attempt, CancellationToken cancellationToken = default)
{
_dbContext.ContestAttempts.Update(attempt);
await _dbContext.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<DbContestAttemptMissionResult>> GetAttemptResultsAsync(int attemptId, CancellationToken cancellationToken = default) =>
await _dbContext.ContestAttemptMissionResults
.Include(r => r.Mission)
.Where(r => r.ContestAttemptId == attemptId)
.ToListAsync(cancellationToken);
}

View File

@@ -1,3 +1,5 @@
using System;
using System.Linq;
using LiquidCode.Domain.Interfaces.Repositories;
using LiquidCode.Infrastructure.Database;
using LiquidCode.Infrastructure.Database.Entities;
@@ -43,12 +45,27 @@ public class GroupRepository : IGroupRepository
public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
_crud.SaveChangesAsync(cancellationToken);
public async Task<DbGroup?> FindWithDetailsAsync(int id, CancellationToken cancellationToken = default) =>
await _dbContext.Groups
public Task<DbGroup?> FindWithDetailsAsync(int id, CancellationToken cancellationToken = default) =>
FindWithDetailsAsync(id, includeSoftDeleted: false, cancellationToken);
public async Task<DbGroup?> FindWithDetailsAsync(int id, bool includeSoftDeleted, CancellationToken cancellationToken = default)
{
var query = _dbContext.Groups
.Include(g => g.Memberships)
.ThenInclude(m => m.User)
.Include(g => g.Contests)
.FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
.Include(g => g.Invitations)
.ThenInclude(i => i.Invitee)
.Include(g => g.JoinTokens)
.AsQueryable();
if (!includeSoftDeleted)
{
query = query.Where(g => !g.IsDeleted);
}
return await query.FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
}
public async Task<(IEnumerable<DbGroup> Items, bool HasNextPage)> GetForUserAsync(
int userId,
@@ -76,7 +93,12 @@ public class GroupRepository : IGroupRepository
return (items, hasNextPage);
}
public async Task UpsertMembershipAsync(int groupId, int userId, GroupMembershipRole role, CancellationToken cancellationToken = default)
public Task<DbGroupMembership?> GetMembershipAsync(int groupId, int userId, CancellationToken cancellationToken = default) =>
_dbContext.GroupMemberships
.Include(m => m.User)
.FirstOrDefaultAsync(m => m.GroupId == groupId && m.UserId == userId, cancellationToken);
public async Task UpsertMembershipAsync(int groupId, int userId, GroupMembershipRole role, GroupMembershipOptions? options, CancellationToken cancellationToken = default)
{
var membership = await _dbContext.GroupMemberships
.FirstOrDefaultAsync(m => m.GroupId == groupId && m.UserId == userId, cancellationToken);
@@ -87,13 +109,24 @@ public class GroupRepository : IGroupRepository
{
GroupId = groupId,
UserId = userId,
Role = role
Role = role,
JoinedAt = options?.JoinedAt ?? DateTime.UtcNow,
InvitedById = options?.InvitedById,
InvitationId = options?.InvitationId,
IsAutoJoined = options?.IsAutoJoined ?? false
};
await _dbContext.GroupMemberships.AddAsync(membership, cancellationToken);
}
else
{
membership.Role = role;
membership.InvitedById = options?.InvitedById ?? membership.InvitedById;
membership.InvitationId = options?.InvitationId ?? membership.InvitationId;
membership.IsAutoJoined = options?.IsAutoJoined ?? membership.IsAutoJoined;
if (options?.JoinedAt != null)
{
membership.JoinedAt = options.JoinedAt.Value;
}
_dbContext.GroupMemberships.Update(membership);
}
@@ -111,4 +144,86 @@ public class GroupRepository : IGroupRepository
await _dbContext.SaveChangesAsync(cancellationToken);
}
}
public async Task<IReadOnlyList<DbGroupInvitation>> GetActiveInvitationsAsync(int groupId, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return await _dbContext.GroupInvitations
.Include(i => i.Invitee)
.Where(i => i.GroupId == groupId && i.Status == GroupInvitationStatus.Pending && i.ExpiresAt > now && i.RevokedAt == null)
.OrderByDescending(i => i.CreatedAt)
.ToListAsync(cancellationToken);
}
public Task<DbGroupInvitation?> GetInvitationByIdAsync(int groupId, int invitationId, CancellationToken cancellationToken = default) =>
_dbContext.GroupInvitations
.Include(i => i.Invitee)
.FirstOrDefaultAsync(i => i.GroupId == groupId && i.Id == invitationId, cancellationToken);
public Task<DbGroupInvitation?> GetInvitationByTokenAsync(string token, CancellationToken cancellationToken = default) =>
_dbContext.GroupInvitations
.Include(i => i.Group)
.ThenInclude(g => g.Memberships)
.Include(i => i.Invitee)
.FirstOrDefaultAsync(i => i.Token == token, cancellationToken);
public async Task<DbGroupInvitation> AddInvitationAsync(DbGroupInvitation invitation, CancellationToken cancellationToken = default)
{
await _dbContext.GroupInvitations.AddAsync(invitation, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return invitation;
}
public async Task SaveInvitationAsync(DbGroupInvitation invitation, CancellationToken cancellationToken = default)
{
_dbContext.GroupInvitations.Update(invitation);
await _dbContext.SaveChangesAsync(cancellationToken);
}
public Task<DbGroupJoinToken?> GetActiveJoinTokenAsync(int groupId, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
return _dbContext.GroupJoinTokens
.Where(t => t.GroupId == groupId && t.RevokedAt == null && t.ExpiresAt > now)
.OrderByDescending(t => t.CreatedAt)
.FirstOrDefaultAsync(cancellationToken);
}
public Task<DbGroupJoinToken?> GetJoinTokenByValueAsync(string token, CancellationToken cancellationToken = default) =>
_dbContext.GroupJoinTokens
.Include(t => t.Group)
.ThenInclude(g => g.Memberships)
.FirstOrDefaultAsync(t => t.Token == token, cancellationToken);
public async Task<DbGroupJoinToken> RotateJoinTokenAsync(int groupId, int createdById, TimeSpan ttl, CancellationToken cancellationToken = default)
{
var now = DateTime.UtcNow;
var activeTokens = await _dbContext.GroupJoinTokens
.Where(t => t.GroupId == groupId && t.RevokedAt == null && t.ExpiresAt > now)
.ToListAsync(cancellationToken);
foreach (var token in activeTokens)
{
token.RevokedAt = now;
token.UpdatedAt = now;
}
var joinToken = new DbGroupJoinToken
{
GroupId = groupId,
CreatedById = createdById,
Token = Guid.NewGuid().ToString("N"),
ExpiresAt = now.Add(ttl),
LastRefreshedAt = now,
UsageCount = 0,
CreatedAt = now,
UpdatedAt = now
};
await _dbContext.GroupJoinTokens.AddAsync(joinToken, cancellationToken);
await _dbContext.SaveChangesAsync(cancellationToken);
return joinToken;
}
}

View File

@@ -46,6 +46,9 @@ public class UserRepository : IUserRepository
public async Task<DbUser?> FindByUsernameAsync(string username, CancellationToken cancellationToken = default) =>
await _dbContext.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
public async Task<DbUser?> FindByEmailAsync(string email, CancellationToken cancellationToken = default) =>
await _dbContext.Users.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
public async Task<bool> UserExistsAsync(string username, CancellationToken cancellationToken = default) =>
await _dbContext.Users.AnyAsync(u => u.Username == username, cancellationToken);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,541 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace LiquidCode.Migrations
{
/// <inheritdoc />
public partial class UpdateContest : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "active_attempt_expires_at",
table: "contest_memberships");
migrationBuilder.DropColumn(
name: "attempt_count",
table: "contest_memberships");
migrationBuilder.RenameColumn(
name: "active_attempt_started_at",
table: "contest_memberships",
newName: "last_attempt_started_at");
migrationBuilder.AddColumn<int>(
name: "contest_attempt_id",
table: "user_submits",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "invitation_id",
table: "group_memberships",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "invited_by_id",
table: "group_memberships",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "is_auto_joined",
table: "group_memberships",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "joined_at",
table: "group_memberships",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.AddColumn<bool>(
name: "allow_early_finish",
table: "contests",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "max_attempts",
table: "contests",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "visibility",
table: "contests",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "active_attempt_id",
table: "contest_memberships",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "invitation_id",
table: "contest_memberships",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "is_auto_joined",
table: "contest_memberships",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "joined_at",
table: "contest_memberships",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
migrationBuilder.CreateTable(
name: "contest_attempts",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
contest_id = table.Column<int>(type: "integer", nullable: false),
user_id = table.Column<int>(type: "integer", nullable: false),
attempt_index = table.Column<int>(type: "integer", nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
finished_by = table.Column<int>(type: "integer", nullable: true),
started_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
finished_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
total_score = table.Column<decimal>(type: "numeric", nullable: false),
solved_count = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_contest_attempts", x => x.id);
table.ForeignKey(
name: "fk_contest_attempts_contest_memberships_contest_id_user_id",
columns: x => new { x.contest_id, x.user_id },
principalTable: "contest_memberships",
principalColumns: new[] { "contest_id", "user_id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_contest_attempts_contests_contest_id",
column: x => x.contest_id,
principalTable: "contests",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_contest_attempts_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "group_invitations",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
group_id = table.Column<int>(type: "integer", nullable: false),
inviter_id = table.Column<int>(type: "integer", nullable: false),
invitee_id = table.Column<int>(type: "integer", nullable: false),
token = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
status = table.Column<int>(type: "integer", nullable: false),
delivery_channel = table.Column<int>(type: "integer", nullable: false),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
accepted_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
declined_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_group_invitations", x => x.id);
table.ForeignKey(
name: "fk_group_invitations_groups_group_id",
column: x => x.group_id,
principalTable: "groups",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_group_invitations_users_invitee_id",
column: x => x.invitee_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_group_invitations_users_inviter_id",
column: x => x.inviter_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "group_join_tokens",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
group_id = table.Column<int>(type: "integer", nullable: false),
created_by_id = table.Column<int>(type: "integer", nullable: false),
token = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
expires_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
revoked_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
last_refreshed_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
usage_count = table.Column<int>(type: "integer", nullable: false),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_group_join_tokens", x => x.id);
table.ForeignKey(
name: "fk_group_join_tokens_groups_group_id",
column: x => x.group_id,
principalTable: "groups",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_group_join_tokens_users_created_by_id",
column: x => x.created_by_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "contest_attempt_mission_results",
columns: table => new
{
contest_attempt_id = table.Column<int>(type: "integer", nullable: false),
mission_id = table.Column<int>(type: "integer", nullable: false),
solved_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
submission_count = table.Column<int>(type: "integer", nullable: false),
highest_score = table.Column<decimal>(type: "numeric", nullable: false),
penalty = table.Column<double>(type: "double precision", nullable: false),
last_submission_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
first_accepted_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
best_submission_id = table.Column<int>(type: "integer", nullable: true),
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_contest_attempt_mission_results", x => new { x.contest_attempt_id, x.mission_id });
table.ForeignKey(
name: "fk_contest_attempt_mission_results_contest_attempts_contest_at",
column: x => x.contest_attempt_id,
principalTable: "contest_attempts",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_contest_attempt_mission_results_missions_mission_id",
column: x => x.mission_id,
principalTable: "missions",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "fk_contest_attempt_mission_results_user_submits_best_submissio",
column: x => x.best_submission_id,
principalTable: "user_submits",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_user_submits_contest_attempt_id",
table: "user_submits",
column: "contest_attempt_id");
migrationBuilder.CreateIndex(
name: "ix_group_memberships_invitation_id",
table: "group_memberships",
column: "invitation_id");
migrationBuilder.CreateIndex(
name: "ix_group_memberships_invited_by_id",
table: "group_memberships",
column: "invited_by_id");
migrationBuilder.CreateIndex(
name: "ix_contests_visibility",
table: "contests",
column: "visibility");
migrationBuilder.CreateIndex(
name: "ix_contest_memberships_active_attempt_id",
table: "contest_memberships",
column: "active_attempt_id");
migrationBuilder.CreateIndex(
name: "ix_contest_memberships_invitation_id",
table: "contest_memberships",
column: "invitation_id");
migrationBuilder.CreateIndex(
name: "ix_contest_attempt_mission_results_best_submission_id",
table: "contest_attempt_mission_results",
column: "best_submission_id");
migrationBuilder.CreateIndex(
name: "ix_contest_attempt_mission_results_mission_id",
table: "contest_attempt_mission_results",
column: "mission_id");
migrationBuilder.CreateIndex(
name: "ix_contest_attempts_contest_id_user_id_attempt_index",
table: "contest_attempts",
columns: new[] { "contest_id", "user_id", "attempt_index" },
unique: true);
migrationBuilder.CreateIndex(
name: "ix_contest_attempts_expires_at",
table: "contest_attempts",
column: "expires_at");
migrationBuilder.CreateIndex(
name: "ix_contest_attempts_started_at",
table: "contest_attempts",
column: "started_at");
migrationBuilder.CreateIndex(
name: "ix_contest_attempts_status",
table: "contest_attempts",
column: "status");
migrationBuilder.CreateIndex(
name: "ix_contest_attempts_user_id",
table: "contest_attempts",
column: "user_id");
migrationBuilder.CreateIndex(
name: "ix_group_invitations_expires_at",
table: "group_invitations",
column: "expires_at");
migrationBuilder.CreateIndex(
name: "ix_group_invitations_group_id_invitee_id_status",
table: "group_invitations",
columns: new[] { "group_id", "invitee_id", "status" });
migrationBuilder.CreateIndex(
name: "ix_group_invitations_invitee_id",
table: "group_invitations",
column: "invitee_id");
migrationBuilder.CreateIndex(
name: "ix_group_invitations_inviter_id",
table: "group_invitations",
column: "inviter_id");
migrationBuilder.CreateIndex(
name: "ix_group_invitations_token",
table: "group_invitations",
column: "token",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_group_join_tokens_created_by_id",
table: "group_join_tokens",
column: "created_by_id");
migrationBuilder.CreateIndex(
name: "ix_group_join_tokens_expires_at",
table: "group_join_tokens",
column: "expires_at");
migrationBuilder.CreateIndex(
name: "ix_group_join_tokens_group_id",
table: "group_join_tokens",
column: "group_id");
migrationBuilder.CreateIndex(
name: "ix_group_join_tokens_token",
table: "group_join_tokens",
column: "token",
unique: true);
migrationBuilder.AddForeignKey(
name: "fk_contest_memberships_contest_attempts_active_attempt_id",
table: "contest_memberships",
column: "active_attempt_id",
principalTable: "contest_attempts",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "fk_contest_memberships_group_invitations_invitation_id",
table: "contest_memberships",
column: "invitation_id",
principalTable: "group_invitations",
principalColumn: "id");
migrationBuilder.AddForeignKey(
name: "fk_group_memberships_group_invitations_invitation_id",
table: "group_memberships",
column: "invitation_id",
principalTable: "group_invitations",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "fk_group_memberships_users_invited_by_id",
table: "group_memberships",
column: "invited_by_id",
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "fk_user_submits_contest_attempts_contest_attempt_id",
table: "user_submits",
column: "contest_attempt_id",
principalTable: "contest_attempts",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_contest_memberships_contest_attempts_active_attempt_id",
table: "contest_memberships");
migrationBuilder.DropForeignKey(
name: "fk_contest_memberships_group_invitations_invitation_id",
table: "contest_memberships");
migrationBuilder.DropForeignKey(
name: "fk_group_memberships_group_invitations_invitation_id",
table: "group_memberships");
migrationBuilder.DropForeignKey(
name: "fk_group_memberships_users_invited_by_id",
table: "group_memberships");
migrationBuilder.DropForeignKey(
name: "fk_user_submits_contest_attempts_contest_attempt_id",
table: "user_submits");
migrationBuilder.DropTable(
name: "contest_attempt_mission_results");
migrationBuilder.DropTable(
name: "group_invitations");
migrationBuilder.DropTable(
name: "group_join_tokens");
migrationBuilder.DropTable(
name: "contest_attempts");
migrationBuilder.DropIndex(
name: "ix_user_submits_contest_attempt_id",
table: "user_submits");
migrationBuilder.DropIndex(
name: "ix_group_memberships_invitation_id",
table: "group_memberships");
migrationBuilder.DropIndex(
name: "ix_group_memberships_invited_by_id",
table: "group_memberships");
migrationBuilder.DropIndex(
name: "ix_contests_visibility",
table: "contests");
migrationBuilder.DropIndex(
name: "ix_contest_memberships_active_attempt_id",
table: "contest_memberships");
migrationBuilder.DropIndex(
name: "ix_contest_memberships_invitation_id",
table: "contest_memberships");
migrationBuilder.DropColumn(
name: "contest_attempt_id",
table: "user_submits");
migrationBuilder.DropColumn(
name: "invitation_id",
table: "group_memberships");
migrationBuilder.DropColumn(
name: "invited_by_id",
table: "group_memberships");
migrationBuilder.DropColumn(
name: "is_auto_joined",
table: "group_memberships");
migrationBuilder.DropColumn(
name: "joined_at",
table: "group_memberships");
migrationBuilder.DropColumn(
name: "allow_early_finish",
table: "contests");
migrationBuilder.DropColumn(
name: "max_attempts",
table: "contests");
migrationBuilder.DropColumn(
name: "visibility",
table: "contests");
migrationBuilder.DropColumn(
name: "active_attempt_id",
table: "contest_memberships");
migrationBuilder.DropColumn(
name: "invitation_id",
table: "contest_memberships");
migrationBuilder.DropColumn(
name: "is_auto_joined",
table: "contest_memberships");
migrationBuilder.DropColumn(
name: "joined_at",
table: "contest_memberships");
migrationBuilder.RenameColumn(
name: "last_attempt_started_at",
table: "contest_memberships",
newName: "active_attempt_started_at");
migrationBuilder.AddColumn<DateTime>(
name: "active_attempt_expires_at",
table: "contest_memberships",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "attempt_count",
table: "contest_memberships",
type: "integer",
nullable: false,
defaultValue: 0);
}
}
}

View File

@@ -111,6 +111,10 @@ namespace LiquidCode.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowEarlyFinish")
.HasColumnType("boolean")
.HasColumnName("allow_early_finish");
b.Property<int?>("AttemptDurationMinutes")
.HasColumnType("integer")
.HasColumnName("attempt_duration_minutes");
@@ -148,6 +152,10 @@ namespace LiquidCode.Migrations
.HasColumnType("boolean")
.HasColumnName("is_deleted");
b.Property<int?>("MaxAttempts")
.HasColumnType("integer")
.HasColumnName("max_attempts");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -166,6 +174,10 @@ namespace LiquidCode.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<int>("Visibility")
.HasColumnType("integer")
.HasColumnName("visibility");
b.HasKey("Id")
.HasName("pk_contests");
@@ -187,6 +199,9 @@ namespace LiquidCode.Migrations
b.HasIndex("StartsAt")
.HasDatabaseName("ix_contests_starts_at");
b.HasIndex("Visibility")
.HasDatabaseName("ix_contests_visibility");
b.ToTable("contests", (string)null);
});
@@ -221,6 +236,143 @@ namespace LiquidCode.Migrations
b.ToTable("contest_articles", (string)null);
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestAttempt", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AttemptIndex")
.HasColumnType("integer")
.HasColumnName("attempt_index");
b.Property<int>("ContestId")
.HasColumnType("integer")
.HasColumnName("contest_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime?>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("finished_at");
b.Property<int?>("FinishedBy")
.HasColumnType("integer")
.HasColumnName("finished_by");
b.Property<int>("SolvedCount")
.HasColumnType("integer")
.HasColumnName("solved_count");
b.Property<DateTime>("StartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("started_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<decimal>("TotalScore")
.HasColumnType("numeric")
.HasColumnName("total_score");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_contest_attempts");
b.HasIndex("ExpiresAt")
.HasDatabaseName("ix_contest_attempts_expires_at");
b.HasIndex("StartedAt")
.HasDatabaseName("ix_contest_attempts_started_at");
b.HasIndex("Status")
.HasDatabaseName("ix_contest_attempts_status");
b.HasIndex("UserId")
.HasDatabaseName("ix_contest_attempts_user_id");
b.HasIndex("ContestId", "UserId", "AttemptIndex")
.IsUnique()
.HasDatabaseName("ix_contest_attempts_contest_id_user_id_attempt_index");
b.ToTable("contest_attempts", (string)null);
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestAttemptMissionResult", b =>
{
b.Property<int>("ContestAttemptId")
.HasColumnType("integer")
.HasColumnName("contest_attempt_id");
b.Property<int>("MissionId")
.HasColumnType("integer")
.HasColumnName("mission_id");
b.Property<int?>("BestSubmissionId")
.HasColumnType("integer")
.HasColumnName("best_submission_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime?>("FirstAcceptedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_accepted_at");
b.Property<decimal>("HighestScore")
.HasColumnType("numeric")
.HasColumnName("highest_score");
b.Property<DateTime?>("LastSubmissionAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_submission_at");
b.Property<double>("Penalty")
.HasColumnType("double precision")
.HasColumnName("penalty");
b.Property<DateTime?>("SolvedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("solved_at");
b.Property<int>("SubmissionCount")
.HasColumnType("integer")
.HasColumnName("submission_count");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("ContestAttemptId", "MissionId")
.HasName("pk_contest_attempt_mission_results");
b.HasIndex("BestSubmissionId")
.HasDatabaseName("ix_contest_attempt_mission_results_best_submission_id");
b.HasIndex("MissionId")
.HasDatabaseName("ix_contest_attempt_mission_results_mission_id");
b.ToTable("contest_attempt_mission_results", (string)null);
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestMembership", b =>
{
b.Property<int>("ContestId")
@@ -231,22 +383,30 @@ namespace LiquidCode.Migrations
.HasColumnType("integer")
.HasColumnName("user_id");
b.Property<DateTime?>("ActiveAttemptExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("active_attempt_expires_at");
b.Property<DateTime?>("ActiveAttemptStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("active_attempt_started_at");
b.Property<int>("AttemptCount")
b.Property<int?>("ActiveAttemptId")
.HasColumnType("integer")
.HasColumnName("attempt_count");
.HasColumnName("active_attempt_id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int?>("InvitationId")
.HasColumnType("integer")
.HasColumnName("invitation_id");
b.Property<bool>("IsAutoJoined")
.HasColumnType("boolean")
.HasColumnName("is_auto_joined");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<DateTime?>("LastAttemptStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_attempt_started_at");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
@@ -258,6 +418,12 @@ namespace LiquidCode.Migrations
b.HasKey("ContestId", "UserId")
.HasName("pk_contest_memberships");
b.HasIndex("ActiveAttemptId")
.HasDatabaseName("ix_contest_memberships_active_attempt_id");
b.HasIndex("InvitationId")
.HasDatabaseName("ix_contest_memberships_invitation_id");
b.HasIndex("Role")
.HasDatabaseName("ix_contest_memberships_role");
@@ -343,6 +509,153 @@ namespace LiquidCode.Migrations
b.ToTable("groups", (string)null);
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroupInvitation", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("AcceptedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("accepted_at");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<DateTime?>("DeclinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("declined_at");
b.Property<int>("DeliveryChannel")
.HasColumnType("integer")
.HasColumnName("delivery_channel");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<int>("GroupId")
.HasColumnType("integer")
.HasColumnName("group_id");
b.Property<int>("InviteeId")
.HasColumnType("integer")
.HasColumnName("invitee_id");
b.Property<int>("InviterId")
.HasColumnType("integer")
.HasColumnName("inviter_id");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_group_invitations");
b.HasIndex("ExpiresAt")
.HasDatabaseName("ix_group_invitations_expires_at");
b.HasIndex("InviteeId")
.HasDatabaseName("ix_group_invitations_invitee_id");
b.HasIndex("InviterId")
.HasDatabaseName("ix_group_invitations_inviter_id");
b.HasIndex("Token")
.IsUnique()
.HasDatabaseName("ix_group_invitations_token");
b.HasIndex("GroupId", "InviteeId", "Status")
.HasDatabaseName("ix_group_invitations_group_id_invitee_id_status");
b.ToTable("group_invitations", (string)null);
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroupJoinToken", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int>("CreatedById")
.HasColumnType("integer")
.HasColumnName("created_by_id");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<int>("GroupId")
.HasColumnType("integer")
.HasColumnName("group_id");
b.Property<DateTime?>("LastRefreshedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_refreshed_at");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked_at");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<int>("UsageCount")
.HasColumnType("integer")
.HasColumnName("usage_count");
b.HasKey("Id")
.HasName("pk_group_join_tokens");
b.HasIndex("CreatedById")
.HasDatabaseName("ix_group_join_tokens_created_by_id");
b.HasIndex("ExpiresAt")
.HasDatabaseName("ix_group_join_tokens_expires_at");
b.HasIndex("GroupId")
.HasDatabaseName("ix_group_join_tokens_group_id");
b.HasIndex("Token")
.IsUnique()
.HasDatabaseName("ix_group_join_tokens_token");
b.ToTable("group_join_tokens", (string)null);
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroupMembership", b =>
{
b.Property<int>("GroupId")
@@ -357,6 +670,22 @@ namespace LiquidCode.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<int?>("InvitationId")
.HasColumnType("integer")
.HasColumnName("invitation_id");
b.Property<int?>("InvitedById")
.HasColumnType("integer")
.HasColumnName("invited_by_id");
b.Property<bool>("IsAutoJoined")
.HasColumnType("boolean")
.HasColumnName("is_auto_joined");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("joined_at");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
@@ -368,6 +697,12 @@ namespace LiquidCode.Migrations
b.HasKey("GroupId", "UserId")
.HasName("pk_group_memberships");
b.HasIndex("InvitationId")
.HasDatabaseName("ix_group_memberships_invitation_id");
b.HasIndex("InvitedById")
.HasDatabaseName("ix_group_memberships_invited_by_id");
b.HasIndex("Role")
.HasDatabaseName("ix_group_memberships_role");
@@ -812,6 +1147,10 @@ namespace LiquidCode.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("ContestAttemptId")
.HasColumnType("integer")
.HasColumnName("contest_attempt_id");
b.Property<int?>("ContestId")
.HasColumnType("integer")
.HasColumnName("contest_id");
@@ -847,6 +1186,9 @@ namespace LiquidCode.Migrations
b.HasKey("Id")
.HasName("pk_user_submits");
b.HasIndex("ContestAttemptId")
.HasDatabaseName("ix_user_submits_contest_attempt_id");
b.HasIndex("ContestId")
.HasDatabaseName("ix_user_submits_contest_id");
@@ -929,8 +1271,73 @@ namespace LiquidCode.Migrations
b.Navigation("Contest");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestAttempt", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContest", "Contest")
.WithMany()
.HasForeignKey("ContestId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_contest_attempts_contests_contest_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_contest_attempts_users_user_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContestMembership", "Membership")
.WithMany("Attempts")
.HasForeignKey("ContestId", "UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_contest_attempts_contest_memberships_contest_id_user_id");
b.Navigation("Contest");
b.Navigation("Membership");
b.Navigation("User");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestAttemptMissionResult", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUserSubmission", "BestSubmission")
.WithMany()
.HasForeignKey("BestSubmissionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_contest_attempt_mission_results_user_submits_best_submissio");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContestAttempt", "ContestAttempt")
.WithMany("MissionResults")
.HasForeignKey("ContestAttemptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_contest_attempt_mission_results_contest_attempts_contest_at");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbMission", "Mission")
.WithMany()
.HasForeignKey("MissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_contest_attempt_mission_results_missions_mission_id");
b.Navigation("BestSubmission");
b.Navigation("ContestAttempt");
b.Navigation("Mission");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestMembership", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContestAttempt", "ActiveAttempt")
.WithMany()
.HasForeignKey("ActiveAttemptId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_contest_memberships_contest_attempts_active_attempt_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContest", "Contest")
.WithMany("Memberships")
.HasForeignKey("ContestId")
@@ -938,6 +1345,11 @@ namespace LiquidCode.Migrations
.IsRequired()
.HasConstraintName("fk_contest_memberships_contests_contest_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbGroupInvitation", "Invitation")
.WithMany()
.HasForeignKey("InvitationId")
.HasConstraintName("fk_contest_memberships_group_invitations_invitation_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "User")
.WithMany()
.HasForeignKey("UserId")
@@ -945,8 +1357,12 @@ namespace LiquidCode.Migrations
.IsRequired()
.HasConstraintName("fk_contest_memberships_users_user_id");
b.Navigation("ActiveAttempt");
b.Navigation("Contest");
b.Navigation("Invitation");
b.Navigation("User");
});
@@ -971,6 +1387,57 @@ namespace LiquidCode.Migrations
b.Navigation("Mission");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroupInvitation", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbGroup", "Group")
.WithMany("Invitations")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_group_invitations_groups_group_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "Invitee")
.WithMany()
.HasForeignKey("InviteeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_group_invitations_users_invitee_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "Inviter")
.WithMany()
.HasForeignKey("InviterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_group_invitations_users_inviter_id");
b.Navigation("Group");
b.Navigation("Invitee");
b.Navigation("Inviter");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroupJoinToken", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "CreatedBy")
.WithMany()
.HasForeignKey("CreatedById")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_group_join_tokens_users_created_by_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbGroup", "Group")
.WithMany("JoinTokens")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_group_join_tokens_groups_group_id");
b.Navigation("CreatedBy");
b.Navigation("Group");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroupMembership", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbGroup", "Group")
@@ -980,6 +1447,18 @@ namespace LiquidCode.Migrations
.IsRequired()
.HasConstraintName("fk_group_memberships_groups_group_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbGroupInvitation", "Invitation")
.WithMany()
.HasForeignKey("InvitationId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_group_memberships_group_invitations_invitation_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "InvitedBy")
.WithMany()
.HasForeignKey("InvitedById")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_group_memberships_users_invited_by_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbUser", "User")
.WithMany()
.HasForeignKey("UserId")
@@ -989,6 +1468,10 @@ namespace LiquidCode.Migrations
b.Navigation("Group");
b.Navigation("Invitation");
b.Navigation("InvitedBy");
b.Navigation("User");
});
@@ -1075,6 +1558,12 @@ namespace LiquidCode.Migrations
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbUserSubmission", b =>
{
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContestAttempt", "ContestAttempt")
.WithMany("Submissions")
.HasForeignKey("ContestAttemptId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_user_submits_contest_attempts_contest_attempt_id");
b.HasOne("LiquidCode.Infrastructure.Database.Entities.DbContest", "Contest")
.WithMany()
.HasForeignKey("ContestId")
@@ -1096,6 +1585,8 @@ namespace LiquidCode.Migrations
b.Navigation("Contest");
b.Navigation("ContestAttempt");
b.Navigation("Solution");
b.Navigation("User");
@@ -1117,10 +1608,26 @@ namespace LiquidCode.Migrations
b.Navigation("Missions");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestAttempt", b =>
{
b.Navigation("MissionResults");
b.Navigation("Submissions");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbContestMembership", b =>
{
b.Navigation("Attempts");
});
modelBuilder.Entity("LiquidCode.Infrastructure.Database.Entities.DbGroup", b =>
{
b.Navigation("Contests");
b.Navigation("Invitations");
b.Navigation("JoinTokens");
b.Navigation("Memberships");
});