014b5edd31
PR Checks / test-and-build (pull_request) Successful in 15m52s
Add format and location steps to the Telegram /newsession wizard, persist offline addresses in sessions.location_address, and render online links/offline addresses in schedule messages. Bump version to 3.10.0.
236 lines
9.6 KiB
C#
236 lines
9.6 KiB
C#
using Dapper;
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Platform;
|
||
using GmRelay.Shared.Rendering;
|
||
using Microsoft.Extensions.Logging;
|
||
using Npgsql;
|
||
|
||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||
|
||
public sealed record LeaveSessionCommand(
|
||
Guid SessionId,
|
||
PlatformUser User,
|
||
string InteractionId,
|
||
PlatformGroup Group,
|
||
PlatformMessageRef ScheduleMessage,
|
||
bool DeferScheduleUpdate = false);
|
||
|
||
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,
|
||
IScheduleMessageUpdateLock scheduleUpdateLock,
|
||
ILogger<LeaveSessionHandler> logger)
|
||
{
|
||
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
|
||
{
|
||
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, 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);
|
||
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
||
}
|
||
|
||
if (SessionStatus.IsCancelled(session.Status))
|
||
{
|
||
await transaction.RollbackAsync(ct);
|
||
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
||
}
|
||
|
||
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);
|
||
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
|
||
}
|
||
|
||
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,
|
||
format AS Format,
|
||
location_address AS LocationAddress
|
||
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,
|
||
p.external_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);
|
||
if (!command.DeferScheduleUpdate)
|
||
{
|
||
await messenger.UpdateScheduleAsync(
|
||
new PlatformScheduleMessage(
|
||
command.Group,
|
||
view,
|
||
command.ScheduleMessage),
|
||
ct);
|
||
}
|
||
|
||
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
||
? "Вы удалены из листа ожидания."
|
||
: promotedDisplayName is null
|
||
? "Вы отписались от сессии."
|
||
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
|
||
|
||
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogError(ex, "Ошибка при самостоятельной отмене записи на сессию {SessionId}", command.SessionId);
|
||
if (!transactionCommitted)
|
||
{
|
||
await transaction.RollbackAsync(ct);
|
||
}
|
||
|
||
var errorText = transactionCommitted
|
||
? "Запись снята, но не удалось обновить сообщение расписания."
|
||
: "Произошла ошибка при отмене записи.";
|
||
return await AnswerAsync(command.InteractionId, errorText, ct);
|
||
}
|
||
}
|
||
|
||
private async Task<SessionInteractionResult> AnswerAsync(
|
||
string interactionId,
|
||
string text,
|
||
CancellationToken ct,
|
||
SessionBatchViewModel? updatedView = null)
|
||
{
|
||
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
|
||
return new SessionInteractionResult(text, updatedView);
|
||
}
|
||
}
|