040b0a3cdb
PR Checks / test-and-build (pull_request) Failing after 13m15s
- Добавлены миграции V024 (backfill + deprecation comments + calendar_subscriptions platform identity) и V025 (backfill proposed_by_external_user_id) - Все Bot handlers переведены с telegram_id/chat_id на platform + external_* - Shared handlers очищены от COALESCE fallback с telegram_* колонками - DiscordBot очищен от COALESCE fallback - Web SessionService и CalendarSubscriptionService переведены на external_* - HandleRsvpHandler: убран legacy UNION с gm_telegram_id, теперь только group_managers - RescheduleVotingFinalizer: переведен на external_username/external_user_id - Tests: добавлены asserts для V024/V025 - Версия обновлена до 3.1.0 Bump version → 3.1.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
347 lines
12 KiB
C#
347 lines
12 KiB
C#
using System.Globalization;
|
|
using Dapper;
|
|
using GmRelay.Shared.Domain;
|
|
using GmRelay.Shared.Features.Notifications;
|
|
using GmRelay.Shared.Platform;
|
|
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
|
|
namespace GmRelay.Shared.Features.Confirmation.HandleRsvp;
|
|
|
|
public sealed record HandleRsvpCommand(
|
|
Guid SessionId,
|
|
PlatformUser User,
|
|
string Status,
|
|
string InteractionId,
|
|
PlatformGroup Group,
|
|
PlatformMessageRef ConfirmationMessage);
|
|
|
|
internal sealed record RsvpCounts(int Total, int Confirmed, int Declined);
|
|
|
|
internal sealed record RsvpSessionContext(
|
|
Guid GroupId,
|
|
string Title,
|
|
DateTime ScheduledAt,
|
|
string Status);
|
|
|
|
internal sealed record ParticipantRsvpRow(
|
|
string Platform,
|
|
string ExternalUserId,
|
|
string DisplayName,
|
|
string? ExternalUsername,
|
|
string RsvpStatus,
|
|
string RegistrationStatus,
|
|
bool IsGm);
|
|
|
|
internal sealed record RsvpRecipientRow(
|
|
string Platform,
|
|
string ExternalUserId,
|
|
string DisplayName,
|
|
string? ExternalUsername);
|
|
|
|
public sealed class HandleRsvpHandler(
|
|
NpgsqlDataSource dataSource,
|
|
IPlatformMessenger messenger,
|
|
ILogger<HandleRsvpHandler> logger)
|
|
{
|
|
public async Task HandleAsync(HandleRsvpCommand command, CancellationToken ct)
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
await using var transaction = await connection.BeginTransactionAsync(ct);
|
|
|
|
var participantExists = await connection.ExecuteScalarAsync<bool>(
|
|
"""
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
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
|
|
AND sp.registration_status = @Active
|
|
)
|
|
""",
|
|
new
|
|
{
|
|
command.SessionId,
|
|
Platform = command.User.Platform.ToString(),
|
|
command.User.ExternalUserId,
|
|
Active = ParticipantRegistrationStatus.Active
|
|
},
|
|
transaction);
|
|
|
|
if (!participantExists)
|
|
{
|
|
await messenger.AnswerInteractionAsync(
|
|
new PlatformInteractionReply(
|
|
command.InteractionId,
|
|
"Вы не являетесь участником этой сессии."),
|
|
ct);
|
|
return;
|
|
}
|
|
|
|
var updated = await connection.ExecuteAsync(
|
|
"""
|
|
UPDATE session_participants
|
|
SET rsvp_status = @Status,
|
|
responded_at = now()
|
|
WHERE session_id = @SessionId
|
|
AND player_id = (
|
|
SELECT id
|
|
FROM players
|
|
WHERE platform = @Platform
|
|
AND external_user_id = @ExternalUserId
|
|
LIMIT 1
|
|
)
|
|
AND registration_status = @Active
|
|
AND rsvp_status != @Status
|
|
""",
|
|
new
|
|
{
|
|
command.SessionId,
|
|
command.Status,
|
|
Platform = command.User.Platform.ToString(),
|
|
command.User.ExternalUserId,
|
|
Active = ParticipantRegistrationStatus.Active
|
|
},
|
|
transaction);
|
|
|
|
if (updated == 0)
|
|
{
|
|
var alreadyText = command.Status == RsvpStatus.Confirmed
|
|
? "Вы уже подтвердили участие."
|
|
: "Вы уже отказались от участия.";
|
|
|
|
await messenger.AnswerInteractionAsync(
|
|
new PlatformInteractionReply(command.InteractionId, alreadyText),
|
|
ct);
|
|
return;
|
|
}
|
|
|
|
var session = await connection.QuerySingleAsync<RsvpSessionContext>(
|
|
"""
|
|
SELECT s.group_id AS GroupId,
|
|
s.title,
|
|
s.scheduled_at AS ScheduledAt,
|
|
s.status AS Status
|
|
FROM sessions s
|
|
WHERE s.id = @SessionId
|
|
""",
|
|
new { command.SessionId },
|
|
transaction);
|
|
|
|
if (command.Status == RsvpStatus.Declined)
|
|
{
|
|
var decision = RsvpFlowRules.Evaluate(
|
|
command.Status,
|
|
session.Status,
|
|
totalParticipants: 0,
|
|
confirmedParticipants: 0);
|
|
|
|
if (decision.ShouldRevertSessionToConfirmationSent)
|
|
{
|
|
await connection.ExecuteAsync(
|
|
"""
|
|
UPDATE sessions
|
|
SET status = @ConfirmationSent, updated_at = now()
|
|
WHERE id = @SessionId AND status = @Confirmed
|
|
""",
|
|
new
|
|
{
|
|
command.SessionId,
|
|
ConfirmationSent = SessionStatus.ConfirmationSent,
|
|
Confirmed = SessionStatus.Confirmed
|
|
},
|
|
transaction);
|
|
}
|
|
|
|
var gmRecipients = (await GetGmRecipientsAsync(connection, session.GroupId, transaction))
|
|
.ToList();
|
|
|
|
await transaction.CommitAsync(ct);
|
|
|
|
if (gmRecipients.Count > 0)
|
|
{
|
|
await messenger.SendRsvpOutcomeAsync(
|
|
new PlatformRsvpOutcomeNotification(
|
|
PlatformRsvpOutcomeKind.GmPlayerDeclined,
|
|
Group: null,
|
|
gmRecipients,
|
|
command.SessionId,
|
|
session.Title,
|
|
session.ScheduledAt,
|
|
ActorDisplayName: command.User.DisplayName),
|
|
ct);
|
|
}
|
|
|
|
await messenger.AnswerInteractionAsync(
|
|
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
|
|
ct);
|
|
}
|
|
else
|
|
{
|
|
var counts = await connection.QuerySingleAsync<RsvpCounts>(
|
|
"""
|
|
SELECT
|
|
count(*) AS Total,
|
|
count(*) FILTER (WHERE rsvp_status = @Confirmed) AS Confirmed,
|
|
count(*) FILTER (WHERE rsvp_status = @Declined) AS Declined
|
|
FROM session_participants
|
|
WHERE session_id = @SessionId AND is_gm = false
|
|
AND registration_status = @Active
|
|
""",
|
|
new
|
|
{
|
|
command.SessionId,
|
|
Confirmed = RsvpStatus.Confirmed,
|
|
Declined = RsvpStatus.Declined,
|
|
Active = ParticipantRegistrationStatus.Active
|
|
},
|
|
transaction);
|
|
|
|
var decision = RsvpFlowRules.Evaluate(command.Status, session.Status, counts.Total, counts.Confirmed);
|
|
|
|
if (decision.ShouldMarkSessionConfirmed)
|
|
{
|
|
await connection.ExecuteAsync(
|
|
"""
|
|
UPDATE sessions
|
|
SET status = @Confirmed, updated_at = now()
|
|
WHERE id = @SessionId
|
|
""",
|
|
new { command.SessionId, Confirmed = SessionStatus.Confirmed },
|
|
transaction);
|
|
}
|
|
|
|
var gmRecipients = decision.ShouldNotifyGm
|
|
? (await GetGmRecipientsAsync(connection, session.GroupId, transaction)).ToList()
|
|
: [];
|
|
|
|
await transaction.CommitAsync(ct);
|
|
|
|
if (decision.ShouldNotifyGroup)
|
|
{
|
|
await messenger.SendRsvpOutcomeAsync(
|
|
new PlatformRsvpOutcomeNotification(
|
|
PlatformRsvpOutcomeKind.GroupAllConfirmed,
|
|
command.Group,
|
|
[],
|
|
command.SessionId,
|
|
session.Title,
|
|
session.ScheduledAt),
|
|
ct);
|
|
}
|
|
|
|
if (decision.ShouldNotifyGm && gmRecipients.Count > 0)
|
|
{
|
|
await messenger.SendRsvpOutcomeAsync(
|
|
new PlatformRsvpOutcomeNotification(
|
|
PlatformRsvpOutcomeKind.GmAllConfirmed,
|
|
Group: null,
|
|
gmRecipients,
|
|
command.SessionId,
|
|
session.Title,
|
|
session.ScheduledAt),
|
|
ct);
|
|
}
|
|
|
|
await messenger.AnswerInteractionAsync(
|
|
new PlatformInteractionReply(command.InteractionId, decision.CallbackText),
|
|
ct);
|
|
}
|
|
|
|
await UpdateConfirmationMessage(command, session, ct);
|
|
}
|
|
|
|
private async Task UpdateConfirmationMessage(
|
|
HandleRsvpCommand command,
|
|
RsvpSessionContext session,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
|
|
|
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
|
|
"""
|
|
SELECT p.platform AS Platform,
|
|
p.external_user_id AS ExternalUserId,
|
|
p.display_name AS DisplayName,
|
|
p.external_username AS ExternalUsername,
|
|
sp.rsvp_status AS RsvpStatus,
|
|
sp.registration_status AS RegistrationStatus,
|
|
sp.is_gm AS IsGm
|
|
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 = @Active
|
|
ORDER BY sp.responded_at NULLS LAST
|
|
""",
|
|
new { command.SessionId, Active = ParticipantRegistrationStatus.Active }))
|
|
.Select(ToParticipant)
|
|
.ToList();
|
|
|
|
var disableActions = participants.Count > 0 &&
|
|
participants.All(participant => participant.RsvpStatus == RsvpStatus.Confirmed);
|
|
|
|
await messenger.UpdateConfirmationRequestAsync(
|
|
new PlatformRsvpMessageUpdate(
|
|
new PlatformConfirmationRequest(
|
|
command.Group,
|
|
command.SessionId,
|
|
session.Title,
|
|
session.ScheduledAt,
|
|
participants,
|
|
command.ConfirmationMessage),
|
|
disableActions),
|
|
ct);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to update confirmation message for session {SessionId}", command.SessionId);
|
|
}
|
|
}
|
|
|
|
private static async Task<IEnumerable<PlatformUser>> GetGmRecipientsAsync(
|
|
NpgsqlConnection connection,
|
|
Guid groupId,
|
|
NpgsqlTransaction transaction)
|
|
{
|
|
var rows = await connection.QueryAsync<RsvpRecipientRow>(
|
|
"""
|
|
SELECT DISTINCT
|
|
p.platform AS Platform,
|
|
p.external_user_id AS ExternalUserId,
|
|
p.display_name AS DisplayName,
|
|
p.external_username AS ExternalUsername
|
|
FROM group_managers gm
|
|
JOIN players p ON p.id = gm.player_id
|
|
WHERE gm.group_id = @GroupId
|
|
""",
|
|
new { GroupId = groupId },
|
|
transaction);
|
|
|
|
return rows.Select(row => new PlatformUser(
|
|
ParsePlatform(row.Platform),
|
|
row.ExternalUserId,
|
|
row.DisplayName,
|
|
row.ExternalUsername));
|
|
}
|
|
|
|
private static PlatformSessionParticipant ToParticipant(ParticipantRsvpRow row) =>
|
|
new(
|
|
new PlatformUser(
|
|
ParsePlatform(row.Platform),
|
|
row.ExternalUserId,
|
|
row.DisplayName,
|
|
row.ExternalUsername),
|
|
row.RsvpStatus,
|
|
row.RegistrationStatus,
|
|
row.IsGm);
|
|
|
|
private static PlatformKind ParsePlatform(string platform) =>
|
|
Enum.Parse<PlatformKind>(platform, ignoreCase: true);
|
|
}
|