212 lines
9.7 KiB
C#
212 lines
9.7 KiB
C#
using System.Globalization;
|
|
using Dapper;
|
|
using Npgsql;
|
|
using GmRelay.Shared.Domain;
|
|
using GmRelay.Shared.Platform;
|
|
using GmRelay.Shared.Rendering;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
|
|
|
public sealed record JoinSessionCommand(
|
|
Guid SessionId,
|
|
PlatformUser User,
|
|
string InteractionId,
|
|
PlatformGroup Group,
|
|
PlatformMessageRef ScheduleMessage,
|
|
bool DeferScheduleUpdate = false);
|
|
|
|
public sealed record SessionInteractionResult(
|
|
string ReplyText,
|
|
SessionBatchViewModel? UpdatedView = null);
|
|
|
|
// DTOs for AOT compilation
|
|
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
|
|
|
|
public sealed class JoinSessionHandler(
|
|
NpgsqlDataSource dataSource,
|
|
IPlatformMessenger messenger,
|
|
IScheduleMessageUpdateLock scheduleUpdateLock,
|
|
ILogger<JoinSessionHandler> logger)
|
|
{
|
|
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand 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
|
|
{
|
|
// 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, status as Status, max_players as MaxPlayers
|
|
FROM sessions
|
|
WHERE id = @SessionId
|
|
FOR UPDATE",
|
|
new { command.SessionId },
|
|
transaction);
|
|
|
|
if (batchInfo is null)
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
|
|
}
|
|
|
|
if (SessionStatus.IsCancelled(batchInfo.Status))
|
|
{
|
|
await transaction.RollbackAsync(ct);
|
|
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
|
|
}
|
|
|
|
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
|
|
? "Вы уже в листе ожидания!"
|
|
: "Вы уже записаны!";
|
|
return await AnswerAsync(command.InteractionId, alreadyText, ct);
|
|
}
|
|
|
|
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);
|
|
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
|
|
}
|
|
|
|
// Загружаем весь батч для перерисовки
|
|
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());
|
|
if (!command.DeferScheduleUpdate)
|
|
{
|
|
await messenger.UpdateScheduleAsync(
|
|
new PlatformScheduleMessage(
|
|
command.Group,
|
|
view,
|
|
command.ScheduleMessage),
|
|
ct);
|
|
}
|
|
|
|
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
|
|
? "Основной состав заполнен. Вы добавлены в лист ожидания."
|
|
: "Вы успешно записаны!";
|
|
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Ошибка при добавлении игрока к сессии");
|
|
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);
|
|
}
|
|
}
|