Обновлены контесты и группы
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m34s
All checks were successful
Build and Push Docker Image / build (push) Successful in 1m34s
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LiquidCode.Api.Groups.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// Запрос на ответ по приглашению в группу
|
||||
/// </summary>
|
||||
public record RespondGroupInvitationRequest(
|
||||
[Required]
|
||||
bool Accept
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -6,12 +6,12 @@ namespace LiquidCode.Domain.Enums;
|
||||
public enum StatementFormat
|
||||
{
|
||||
/// <summary>
|
||||
/// Формат LaTeX (каталоги вида statements/<language>)
|
||||
/// Формат LaTeX (каталоги вида statements/<language>)
|
||||
/// </summary>
|
||||
Latex = 0,
|
||||
|
||||
/// <summary>
|
||||
/// HTML представление (каталоги вида statements/.html/<language>)
|
||||
/// HTML представление (каталоги вида statements/.html/<language>)
|
||||
/// </summary>
|
||||
Html = 1
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
1660
LiquidCode/Migrations/20251104121333_UpdateContest.Designer.cs
generated
Normal file
1660
LiquidCode/Migrations/20251104121333_UpdateContest.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
541
LiquidCode/Migrations/20251104121333_UpdateContest.cs
Normal file
541
LiquidCode/Migrations/20251104121333_UpdateContest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user