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.
191 lines
8.8 KiB
C#
191 lines
8.8 KiB
C#
using System.Globalization;
|
|
using Dapper;
|
|
using Npgsql;
|
|
using GmRelay.Shared.Domain;
|
|
using GmRelay.Shared.Platform;
|
|
using GmRelay.Shared.Rendering;
|
|
|
|
namespace GmRelay.Bot.Features.Sessions.CreateSession;
|
|
|
|
public sealed record JoinSessionCommand(
|
|
Guid SessionId,
|
|
PlatformUser User,
|
|
string InteractionId,
|
|
PlatformGroup Group,
|
|
PlatformMessageRef ScheduleMessage);
|
|
|
|
// DTOs for AOT compilation
|
|
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, int? MaxPlayers);
|
|
|
|
public sealed class JoinSessionHandler(
|
|
NpgsqlDataSource dataSource,
|
|
IPlatformMessenger messenger,
|
|
ILogger<JoinSessionHandler> logger)
|
|
{
|
|
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
var transactionCommitted = false;
|
|
|
|
try
|
|
{
|
|
// 1. Убеждаемся, что игрок есть в базе
|
|
var platform = command.User.Platform.ToString();
|
|
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
|
|
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
|
|
: (long?)null;
|
|
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
|
|
? command.User.ExternalUsername
|
|
: null;
|
|
|
|
var playerId = await connection.ExecuteScalarAsync<Guid>(
|
|
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
|
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
|
ON CONFLICT (platform, external_user_id)
|
|
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
|
DO UPDATE
|
|
SET display_name = EXCLUDED.display_name,
|
|
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
|
|
platform = EXCLUDED.platform,
|
|
external_user_id = EXCLUDED.external_user_id,
|
|
external_username = EXCLUDED.external_username
|
|
RETURNING id;",
|
|
new
|
|
{
|
|
LegacyTelegramId = legacyTelegramId,
|
|
Name = command.User.DisplayName,
|
|
LegacyTelegramUsername = legacyTelegramUsername,
|
|
Platform = platform,
|
|
command.User.ExternalUserId,
|
|
command.User.ExternalUsername
|
|
},
|
|
transaction);
|
|
|
|
// 2. Блокируем сессию на время расчета мест, чтобы параллельные нажатия не переполнили состав.
|
|
var batchInfo = await connection.QuerySingleOrDefaultAsync<JoinSessionBatchDto>(
|
|
@"SELECT batch_id as BatchId, title as Title, max_players as MaxPlayers
|
|
FROM sessions
|
|
WHERE id = @SessionId
|
|
FOR UPDATE",
|
|
new { command.SessionId },
|
|
transaction);
|
|
|
|
if (batchInfo is null)
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
|
return;
|
|
}
|
|
|
|
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
|
|
"""
|
|
SELECT sp.registration_status
|
|
FROM session_participants sp
|
|
WHERE sp.session_id = @SessionId
|
|
AND sp.player_id = @PlayerId
|
|
AND sp.is_gm = false
|
|
""",
|
|
new { command.SessionId, PlayerId = playerId },
|
|
transaction);
|
|
|
|
if (existingRegistrationStatus is not null)
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
|
|
? "Вы уже в листе ожидания!"
|
|
: "Вы уже записаны!";
|
|
await AnswerAsync(command.InteractionId, alreadyText, ct);
|
|
return;
|
|
}
|
|
|
|
var activeParticipants = 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 registrationStatus = SessionCapacityRules.DecideJoinStatus(batchInfo.MaxPlayers, activeParticipants);
|
|
|
|
// 3. Добавляем в основной состав или лист ожидания. RSVP остается Pending до финального подтверждения.
|
|
var inserted = await connection.ExecuteAsync(
|
|
@"INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status)
|
|
VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus)
|
|
ON CONFLICT (session_id, player_id) DO NOTHING;",
|
|
new
|
|
{
|
|
command.SessionId,
|
|
PlayerId = playerId,
|
|
Pending = RsvpStatus.Pending,
|
|
RegistrationStatus = registrationStatus
|
|
},
|
|
transaction);
|
|
|
|
if (inserted == 0)
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
|
return;
|
|
}
|
|
|
|
// Загружаем весь батч для перерисовки
|
|
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 { BatchId = batchInfo.BatchId }, transaction);
|
|
|
|
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 { BatchId = batchInfo.BatchId }, transaction);
|
|
|
|
await transaction.CommitAsync(ct);
|
|
transactionCommitted = true;
|
|
|
|
// 4. Перерисовываем сообщение
|
|
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
|
|
await messenger.UpdateScheduleAsync(
|
|
new PlatformScheduleMessage(
|
|
command.Group,
|
|
view,
|
|
command.ScheduleMessage),
|
|
ct);
|
|
|
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
|
: "Вы успешно записаны!";
|
|
await AnswerAsync(command.InteractionId, callbackText, ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
|
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);
|
|
}
|