e791fc2f4a
PR Checks / test-and-build (pull_request) Successful in 5m3s
Convert join/leave interaction commands to PlatformUser, PlatformGroup, and PlatformMessageRef. Persist and look up participants by platform identity while keeping Telegram callbacks intact. Add V017 migration and TDD coverage. Bump version to 2.1.1.
223 lines
9.1 KiB
C#
223 lines
9.1 KiB
C#
using Dapper;
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Platform;
|
||
using GmRelay.Shared.Rendering;
|
||
using Npgsql;
|
||
|
||
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
||
|
||
public sealed record LeaveSessionCommand(
|
||
Guid SessionId,
|
||
PlatformUser User,
|
||
string InteractionId,
|
||
PlatformGroup Group,
|
||
PlatformMessageRef ScheduleMessage);
|
||
|
||
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
|
||
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
|
||
internal sealed record LeaveSessionPromotionDto(Guid ParticipantRowId, string DisplayName);
|
||
|
||
public sealed class LeaveSessionHandler(
|
||
NpgsqlDataSource dataSource,
|
||
IPlatformMessenger messenger,
|
||
ILogger<LeaveSessionHandler> logger)
|
||
{
|
||
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||
{
|
||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||
var transactionCommitted = false;
|
||
|
||
try
|
||
{
|
||
var session = await connection.QuerySingleOrDefaultAsync<LeaveSessionInfoDto>(
|
||
"""
|
||
SELECT title AS Title,
|
||
batch_id AS BatchId,
|
||
status AS Status,
|
||
max_players AS MaxPlayers
|
||
FROM sessions
|
||
WHERE id = @SessionId
|
||
FOR UPDATE
|
||
""",
|
||
new { command.SessionId },
|
||
transaction);
|
||
|
||
if (session is null)
|
||
{
|
||
await transaction.RollbackAsync(ct);
|
||
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||
return;
|
||
}
|
||
|
||
if (SessionStatus.IsCancelled(session.Status))
|
||
{
|
||
await transaction.RollbackAsync(ct);
|
||
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||
return;
|
||
}
|
||
|
||
var platform = command.User.Platform.ToString();
|
||
|
||
var participant = await connection.QuerySingleOrDefaultAsync<LeaveSessionParticipantDto>(
|
||
"""
|
||
SELECT sp.id AS ParticipantRowId,
|
||
p.display_name AS DisplayName,
|
||
sp.registration_status AS RegistrationStatus
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId
|
||
AND p.platform = @Platform
|
||
AND p.external_user_id = @ExternalUserId
|
||
AND sp.is_gm = false
|
||
FOR UPDATE OF sp
|
||
""",
|
||
new { command.SessionId, Platform = platform, command.User.ExternalUserId },
|
||
transaction);
|
||
|
||
if (participant is null)
|
||
{
|
||
await transaction.RollbackAsync(ct);
|
||
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||
return;
|
||
}
|
||
|
||
await connection.ExecuteAsync(
|
||
"""
|
||
DELETE FROM session_participants
|
||
WHERE id = @ParticipantRowId
|
||
""",
|
||
new { participant.ParticipantRowId },
|
||
transaction);
|
||
|
||
var activeParticipantsAfterLeave = await connection.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM session_participants
|
||
WHERE session_id = @SessionId
|
||
AND is_gm = false
|
||
AND registration_status = @Active
|
||
""",
|
||
new { command.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||
transaction);
|
||
|
||
var waitlistedParticipants = await connection.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM session_participants
|
||
WHERE session_id = @SessionId
|
||
AND is_gm = false
|
||
AND registration_status = @Waitlisted
|
||
""",
|
||
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
string? promotedDisplayName = null;
|
||
if (SessionCapacityRules.ShouldPromoteAfterParticipantLeaves(
|
||
participant.RegistrationStatus,
|
||
session.MaxPlayers,
|
||
activeParticipantsAfterLeave,
|
||
waitlistedParticipants))
|
||
{
|
||
var promoted = await connection.QuerySingleAsync<LeaveSessionPromotionDto>(
|
||
"""
|
||
SELECT sp.id AS ParticipantRowId,
|
||
p.display_name AS DisplayName
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Waitlisted
|
||
ORDER BY sp.created_at ASC, sp.id ASC
|
||
LIMIT 1
|
||
FOR UPDATE OF sp
|
||
""",
|
||
new { command.SessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
await connection.ExecuteAsync(
|
||
"""
|
||
UPDATE session_participants
|
||
SET registration_status = @Active,
|
||
rsvp_status = @Pending,
|
||
responded_at = NULL
|
||
WHERE id = @ParticipantRowId
|
||
""",
|
||
new
|
||
{
|
||
promoted.ParticipantRowId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Pending = RsvpStatus.Pending
|
||
},
|
||
transaction);
|
||
|
||
promotedDisplayName = promoted.DisplayName;
|
||
}
|
||
|
||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||
"""
|
||
SELECT id AS SessionId,
|
||
scheduled_at AS ScheduledAt,
|
||
status AS Status,
|
||
max_players AS MaxPlayers,
|
||
join_link AS JoinLink
|
||
FROM sessions
|
||
WHERE batch_id = @BatchId
|
||
ORDER BY scheduled_at
|
||
""",
|
||
new { session.BatchId },
|
||
transaction)).ToList();
|
||
|
||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||
"""
|
||
SELECT sp.session_id AS SessionId,
|
||
p.display_name AS DisplayName,
|
||
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
|
||
sp.registration_status AS RegistrationStatus
|
||
FROM session_participants sp
|
||
JOIN players p ON sp.player_id = p.id
|
||
JOIN sessions s ON sp.session_id = s.id
|
||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||
""",
|
||
new { session.BatchId },
|
||
transaction)).ToList();
|
||
|
||
await transaction.CommitAsync(ct);
|
||
transactionCommitted = true;
|
||
|
||
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
|
||
await messenger.UpdateScheduleAsync(
|
||
new PlatformScheduleMessage(
|
||
command.Group,
|
||
view,
|
||
command.ScheduleMessage),
|
||
ct);
|
||
|
||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||
? "Вы удалены из листа ожидания."
|
||
: promotedDisplayName is null
|
||
? "Вы отписались от сессии."
|
||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||
|
||
await AnswerAsync(command.InteractionId, callbackText, ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId);
|
||
if (!transactionCommitted)
|
||
{
|
||
await transaction.RollbackAsync(ct);
|
||
}
|
||
|
||
var errorText = transactionCommitted
|
||
? "Запись снята, но не удалось обновить сообщение расписания."
|
||
: "Произошла ошибка при отмене записи.";
|
||
await AnswerAsync(command.InteractionId, errorText, ct);
|
||
}
|
||
}
|
||
|
||
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
|
||
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||
}
|