refactor(shared): extract reschedule voting types to Shared

This commit is contained in:
2026-05-20 11:44:57 +03:00
parent fb0c29eefe
commit fcd7de035f
12 changed files with 1519 additions and 34 deletions
@@ -0,0 +1,22 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleOptionDto(
Guid OptionId,
int DisplayOrder,
DateTimeOffset ProposedAt);
public sealed record RescheduleOptionVoteDto(
Guid OptionId,
Guid PlayerId,
string DisplayName,
string? TelegramUsername);
public sealed record RescheduleOptionVoteCount(
Guid OptionId,
int VoteCount);
public sealed record VoteParticipantDto(
Guid PlayerId,
string DisplayName,
string? TelegramUsername,
long TelegramId = 0);
@@ -0,0 +1,69 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public enum RescheduleVoteOutcome
{
Pending,
Rejected,
Approved
}
public sealed record RescheduleVoteDecision(
RescheduleVoteOutcome Outcome,
string Reason,
Guid? SelectedOptionId = null,
string CallbackText = "",
bool ShouldRescheduleSession = false,
bool ShouldResetParticipantRsvps = false);
public static class RescheduleVoteRules
{
public static RescheduleVoteDecision SelectWinner(IReadOnlyList<RescheduleOptionVoteCount> voteCounts)
{
var maxVotes = voteCounts.Count == 0 ? 0 : voteCounts.Max(x => x.VoteCount);
if (maxVotes == 0)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Никто не проголосовал до дедлайна, перенос не применяется.");
}
var winners = voteCounts.Where(x => x.VoteCount == maxVotes).ToList();
if (winners.Count > 1)
{
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Rejected,
"Голоса разделились поровну, перенос не применяется.");
}
return new RescheduleVoteDecision(
RescheduleVoteOutcome.Approved,
"Победил вариант с большинством голосов.",
winners[0].OptionId,
ShouldRescheduleSession: true,
ShouldResetParticipantRsvps: true);
}
public static RescheduleVoteDecision Evaluate(string vote, int totalParticipants, int approvedParticipants)
{
if (string.Equals(vote, "no", StringComparison.OrdinalIgnoreCase))
{
return new RescheduleVoteDecision(
Outcome: RescheduleVoteOutcome.Rejected,
Reason: "Один из участников отклонил перенос.",
CallbackText: "Вы проголосовали против переноса.");
}
var everyoneApproved = approvedParticipants == totalParticipants;
return new RescheduleVoteDecision(
Outcome: everyoneApproved ? RescheduleVoteOutcome.Approved : RescheduleVoteOutcome.Pending,
Reason: everyoneApproved
? "Все участники согласны."
: "Голосование продолжается.",
CallbackText: everyoneApproved
? "Вы подтвердили перенос! Все согласны — время обновлено."
: "Вы подтвердили перенос!",
ShouldRescheduleSession: everyoneApproved,
ShouldResetParticipantRsvps: everyoneApproved);
}
}
@@ -0,0 +1,95 @@
using System.Text.RegularExpressions;
using GmRelay.Shared.Domain;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record RescheduleVotingInput(
IReadOnlyList<DateTimeOffset> Options,
DateTimeOffset Deadline)
{
private static readonly Regex DateTimePattern = new(
@"(?<date>\d{1,2}\.\d{2}\.\d{4})\s+(?<time>\d{1,2}:\d{2})",
RegexOptions.CultureInvariant);
public static bool TryParse(
string text,
DateTimeOffset nowUtc,
out RescheduleVotingInput input,
out string error)
{
input = new RescheduleVotingInput([], default);
error = string.Empty;
var options = new List<DateTimeOffset>();
DateTimeOffset? deadline = null;
foreach (var rawLine in text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var line = rawLine.Trim();
var match = DateTimePattern.Match(line);
if (!match.Success)
continue;
var value = $"{match.Groups["date"].Value} {match.Groups["time"].Value}";
if (!MoscowTime.TryParseMoscow(value, out var parsed))
continue;
if (IsDeadlineLine(line))
{
deadline = parsed;
}
else
{
options.Add(parsed);
}
}
if (options.Count is < 2 or > 3)
{
error = "Укажите от 2 до 3 вариантов времени.";
return false;
}
if (options.Distinct().Count() != options.Count)
{
error = "Варианты времени не должны повторяться.";
return false;
}
if (deadline is null)
{
error = "Укажите дедлайн голосования строкой «Дедлайн: ДД.ММ.ГГГГ ЧЧ:ММ».";
return false;
}
if (options.Any(option => option <= nowUtc))
{
error = "Все варианты времени должны быть в будущем.";
return false;
}
if (deadline.Value <= nowUtc)
{
error = "Дедлайн голосования должен быть в будущем.";
return false;
}
if (deadline.Value >= options.Min())
{
error = "Дедлайн голосования должен быть раньше первого предложенного времени.";
return false;
}
input = new RescheduleVotingInput(options, deadline.Value);
return true;
}
private static bool IsDeadlineLine(string line)
{
var normalized = line.TrimStart('-', '*', ' ', '\t').ToLowerInvariant();
return normalized.StartsWith("дедлайн", StringComparison.Ordinal)
|| normalized.StartsWith("deadline", StringComparison.Ordinal)
|| normalized.StartsWith("до:", StringComparison.Ordinal);
}
}