1090 lines
42 KiB
C#
1090 lines
42 KiB
C#
using Dapper;
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Rendering;
|
||
using Npgsql;
|
||
using Telegram.Bot;
|
||
|
||
namespace GmRelay.Web.Services;
|
||
|
||
public sealed record WebGameGroup(
|
||
Guid Id,
|
||
long TelegramChatId,
|
||
string Name,
|
||
long GmTelegramId,
|
||
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
|
||
|
||
public sealed record WebGroupManager(
|
||
long TelegramId,
|
||
string DisplayName,
|
||
string? TelegramUsername,
|
||
string Role,
|
||
DateTime AddedAt);
|
||
|
||
public sealed record WebGroupManagement(
|
||
WebGameGroup Group,
|
||
IReadOnlyList<WebGroupManager> Managers,
|
||
bool CurrentUserIsOwner);
|
||
public sealed record WebSession(
|
||
Guid Id,
|
||
Guid GroupId,
|
||
string Title,
|
||
DateTime ScheduledAt,
|
||
string Status,
|
||
string JoinLink,
|
||
Guid BatchId,
|
||
int? BatchMessageId,
|
||
long TelegramChatId,
|
||
int? MaxPlayers,
|
||
int ActivePlayerCount,
|
||
int WaitlistedPlayerCount,
|
||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue);
|
||
|
||
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
|
||
internal sealed record WebBatchInfo(
|
||
Guid BatchId,
|
||
Guid GroupId,
|
||
string Title,
|
||
string JoinLink,
|
||
long TelegramChatId,
|
||
int? BatchMessageId,
|
||
int? ThreadId,
|
||
string NotificationMode);
|
||
|
||
internal sealed record WebBatchSessionRow(
|
||
Guid Id,
|
||
Guid GroupId,
|
||
string Title,
|
||
string JoinLink,
|
||
DateTime ScheduledAt,
|
||
string Status,
|
||
int? MaxPlayers,
|
||
int? BatchMessageId,
|
||
long TelegramChatId,
|
||
int? ThreadId,
|
||
string NotificationMode);
|
||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||
|
||
public sealed class SessionService(
|
||
NpgsqlDataSource dataSource,
|
||
ITelegramBotClient bot,
|
||
ILogger<SessionService> logger) : ISessionStore
|
||
{
|
||
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebGameGroup>(
|
||
"""
|
||
SELECT g.id,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
g.name,
|
||
g.gm_telegram_id AS GmTelegramId,
|
||
gm.role AS ManagerRole
|
||
FROM group_managers gm
|
||
JOIN players p ON p.id = gm.player_id
|
||
JOIN game_groups g ON g.id = gm.group_id
|
||
WHERE p.telegram_id = @GmId
|
||
ORDER BY g.name
|
||
""",
|
||
new { GmId = gmId })).ToList();
|
||
}
|
||
|
||
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||
"""
|
||
SELECT g.id,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
g.name,
|
||
g.gm_telegram_id AS GmTelegramId,
|
||
@OwnerRole AS ManagerRole
|
||
FROM game_groups g
|
||
WHERE g.id = @GroupId
|
||
""",
|
||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||
}
|
||
|
||
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.ExecuteScalarAsync<bool>(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM group_managers gm
|
||
JOIN players p ON p.id = gm.player_id
|
||
WHERE gm.group_id = @GroupId
|
||
AND p.telegram_id = @TelegramId
|
||
)
|
||
""",
|
||
new { GroupId = groupId, TelegramId = telegramId });
|
||
}
|
||
|
||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.ExecuteScalarAsync<bool>(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM group_managers gm
|
||
JOIN players p ON p.id = gm.player_id
|
||
WHERE gm.group_id = @GroupId
|
||
AND p.telegram_id = @TelegramId
|
||
AND gm.role = @OwnerRole
|
||
)
|
||
""",
|
||
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||
}
|
||
|
||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebGroupManager>(
|
||
"""
|
||
SELECT p.telegram_id AS TelegramId,
|
||
p.display_name AS DisplayName,
|
||
p.telegram_username AS TelegramUsername,
|
||
gm.role AS Role,
|
||
gm.created_at AS AddedAt
|
||
FROM group_managers gm
|
||
JOIN players p ON p.id = gm.player_id
|
||
WHERE gm.group_id = @GroupId
|
||
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
|
||
gm.created_at,
|
||
p.display_name
|
||
""",
|
||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
|
||
}
|
||
|
||
public async Task AddGroupCoGmAsync(
|
||
Guid groupId,
|
||
long ownerTelegramId,
|
||
long coGmTelegramId,
|
||
string displayName,
|
||
string? telegramUsername)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO players (telegram_id, display_name, telegram_username)
|
||
VALUES (@TelegramId, @DisplayName, @TelegramUsername)
|
||
ON CONFLICT (telegram_id) DO UPDATE
|
||
SET display_name = EXCLUDED.display_name,
|
||
telegram_username = EXCLUDED.telegram_username
|
||
""",
|
||
new
|
||
{
|
||
TelegramId = coGmTelegramId,
|
||
DisplayName = displayName,
|
||
TelegramUsername = telegramUsername
|
||
},
|
||
transaction);
|
||
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
|
||
SELECT @GroupId,
|
||
co_gm.id,
|
||
@CoGmRole,
|
||
owner_player.id
|
||
FROM players co_gm
|
||
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
|
||
WHERE co_gm.telegram_id = @CoGmTelegramId
|
||
ON CONFLICT (group_id, player_id) DO UPDATE
|
||
SET role = CASE
|
||
WHEN group_managers.role = @OwnerRole THEN group_managers.role
|
||
ELSE EXCLUDED.role
|
||
END,
|
||
added_by_player_id = EXCLUDED.added_by_player_id
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
OwnerTelegramId = ownerTelegramId,
|
||
CoGmTelegramId = coGmTelegramId,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||
},
|
||
transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
}
|
||
|
||
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
DELETE FROM group_managers gm
|
||
USING players p
|
||
WHERE gm.player_id = p.id
|
||
AND gm.group_id = @GroupId
|
||
AND p.telegram_id = @CoGmTelegramId
|
||
AND gm.role = @CoGmRole
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
CoGmTelegramId = coGmTelegramId,
|
||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||
});
|
||
}
|
||
|
||
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(*) AS count
|
||
FROM session_participants sp
|
||
WHERE sp.session_id = s.id
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Active
|
||
) active_counts ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(*) AS count
|
||
FROM session_participants sp
|
||
WHERE sp.session_id = s.id
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Waitlisted
|
||
) waitlist_counts ON true
|
||
WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours'
|
||
ORDER BY s.scheduled_at",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||
})).ToList();
|
||
}
|
||
|
||
public async Task<WebSession?> GetSessionAsync(Guid sessionId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(*) AS count
|
||
FROM session_participants sp
|
||
WHERE sp.session_id = s.id
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Active
|
||
) active_counts ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(*) AS count
|
||
FROM session_participants sp
|
||
WHERE sp.session_id = s.id
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Waitlisted
|
||
) waitlist_counts ON true
|
||
WHERE s.id = @SessionId",
|
||
new
|
||
{
|
||
SessionId = sessionId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||
});
|
||
}
|
||
|
||
public async Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebSessionBatch>(
|
||
"""
|
||
SELECT s.batch_id AS Id,
|
||
s.group_id AS GroupId,
|
||
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
||
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
||
MIN(s.scheduled_at) AS FirstScheduledAt,
|
||
MAX(s.scheduled_at) AS LastScheduledAt,
|
||
COUNT(*)::int AS SessionCount,
|
||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||
FROM sessions s
|
||
WHERE s.batch_id = @BatchId
|
||
GROUP BY s.batch_id, s.group_id
|
||
""",
|
||
new { BatchId = batchId });
|
||
}
|
||
|
||
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
0 AS ActivePlayerCount,
|
||
0 AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||
new { Id = sessionId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (oldSession is null)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, 0);
|
||
}
|
||
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
@"UPDATE sessions
|
||
SET title = @Title,
|
||
scheduled_at = @ScheduledAt,
|
||
join_link = @JoinLink,
|
||
max_players = @MaxPlayers,
|
||
one_hour_reminder_processed_at = CASE
|
||
WHEN scheduled_at <> @ScheduledAt THEN NULL
|
||
ELSE one_hour_reminder_processed_at
|
||
END,
|
||
updated_at = now()
|
||
WHERE id = @Id AND group_id = @GroupId",
|
||
new
|
||
{
|
||
Id = sessionId,
|
||
GroupId = groupId,
|
||
Title = title,
|
||
ScheduledAt = scheduledAt,
|
||
JoinLink = joinLink,
|
||
MaxPlayers = maxPlayers
|
||
},
|
||
transaction);
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, 0);
|
||
}
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
|
||
new { Title = title, BatchId = oldSession.BatchId },
|
||
transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var timeChanged = oldSession.ScheduledAt != scheduledAt;
|
||
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
|
||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
|
||
$"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : "") +
|
||
"\n" +
|
||
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
|
||
|
||
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
|
||
if (mode.ShouldSendDirectMessages())
|
||
{
|
||
var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId);
|
||
await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId);
|
||
}
|
||
|
||
if (oldSession.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
|
||
}
|
||
}
|
||
|
||
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
0 AS ActivePlayerCount,
|
||
0 AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||
FOR UPDATE",
|
||
new { SessionId = sessionId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (session is null)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, 0);
|
||
}
|
||
|
||
var activeParticipants = await conn.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM session_participants
|
||
WHERE session_id = @SessionId
|
||
AND is_gm = false
|
||
AND registration_status = @Active
|
||
""",
|
||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
|
||
transaction);
|
||
|
||
var waitlistedParticipants = await conn.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM session_participants
|
||
WHERE session_id = @SessionId
|
||
AND is_gm = false
|
||
AND registration_status = @Waitlisted
|
||
""",
|
||
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||
{
|
||
throw new InvalidOperationException(waitlistedParticipants == 0
|
||
? "Лист ожидания пуст."
|
||
: "Нет свободных мест для повышения игрока.");
|
||
}
|
||
|
||
var promoted = await conn.QuerySingleAsync<WebPromotedParticipantDto>(
|
||
"""
|
||
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 { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
await conn.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);
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
await bot.SendMessage(
|
||
session.TelegramChatId,
|
||
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
if (session.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||
}
|
||
}
|
||
|
||
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
||
if (batch is null)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, 0);
|
||
}
|
||
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET title = @Title,
|
||
join_link = @JoinLink,
|
||
updated_at = now()
|
||
WHERE batch_id = @BatchId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new
|
||
{
|
||
BatchId = batchId,
|
||
GroupId = groupId,
|
||
Title = title,
|
||
JoinLink = joinLink
|
||
},
|
||
transaction);
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, 0);
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
if (batch.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title);
|
||
}
|
||
}
|
||
|
||
public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
||
if (batch is null)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, 0);
|
||
}
|
||
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET notification_mode = @NotificationMode,
|
||
updated_at = now()
|
||
WHERE batch_id = @BatchId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new
|
||
{
|
||
BatchId = batchId,
|
||
GroupId = groupId,
|
||
NotificationMode = notificationMode.ToDatabaseValue()
|
||
},
|
||
transaction);
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, 0);
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
}
|
||
|
||
public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var batchSessions = (await conn.QueryAsync<WebBatchSessionRow>(
|
||
"""
|
||
SELECT s.id AS Id,
|
||
s.group_id AS GroupId,
|
||
s.title AS Title,
|
||
s.join_link AS JoinLink,
|
||
s.scheduled_at AS ScheduledAt,
|
||
s.status AS Status,
|
||
s.max_players AS MaxPlayers,
|
||
s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
s.thread_id AS ThreadId,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND s.group_id = @GroupId
|
||
ORDER BY s.scheduled_at
|
||
FOR UPDATE
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId },
|
||
transaction)).ToList();
|
||
|
||
if (batchSessions.Count == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, 0);
|
||
}
|
||
|
||
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
|
||
batchSessions.Select(session => session.ScheduledAt),
|
||
firstScheduledAt,
|
||
intervalDays);
|
||
|
||
for (var index = 0; index < batchSessions.Count; index++)
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET scheduled_at = @ScheduledAt,
|
||
one_hour_reminder_processed_at = NULL,
|
||
updated_at = now()
|
||
WHERE id = @SessionId
|
||
""",
|
||
new
|
||
{
|
||
SessionId = batchSessions[index].Id,
|
||
ScheduledAt = newSchedule[index]
|
||
},
|
||
transaction);
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var firstSession = batchSessions[0];
|
||
if (firstSession.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title);
|
||
}
|
||
|
||
var notification = $"🔄 <b>Мастер обновил расписание пачки</b>\n\n" +
|
||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(firstSession.Title)}</b>\n" +
|
||
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
|
||
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
|
||
|
||
await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
|
||
if (mode.ShouldSendDirectMessages())
|
||
{
|
||
var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId);
|
||
await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId);
|
||
}
|
||
}
|
||
|
||
public async Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var sourceSessions = (await conn.QueryAsync<WebBatchSessionRow>(
|
||
"""
|
||
SELECT s.id AS Id,
|
||
s.group_id AS GroupId,
|
||
s.title AS Title,
|
||
s.join_link AS JoinLink,
|
||
s.scheduled_at AS ScheduledAt,
|
||
s.status AS Status,
|
||
s.max_players AS MaxPlayers,
|
||
s.batch_message_id AS BatchMessageId,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
s.thread_id AS ThreadId,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND s.group_id = @GroupId
|
||
ORDER BY s.scheduled_at
|
||
FOR UPDATE
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId },
|
||
transaction)).ToList();
|
||
|
||
if (sourceSessions.Count == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, 0);
|
||
}
|
||
|
||
var newBatchId = Guid.NewGuid();
|
||
var batchTitle = sourceSessions[0].Title;
|
||
var batchJoinLink = sourceSessions[0].JoinLink;
|
||
var chatId = sourceSessions[0].TelegramChatId;
|
||
var threadId = sourceSessions[0].ThreadId;
|
||
var renderedSessions = new List<SessionBatchDto>();
|
||
|
||
foreach (var sourceSession in sourceSessions)
|
||
{
|
||
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||
"""
|
||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players, notification_mode)
|
||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode)
|
||
RETURNING id
|
||
""",
|
||
new
|
||
{
|
||
BatchId = newBatchId,
|
||
sourceSession.GroupId,
|
||
Title = batchTitle,
|
||
JoinLink = batchJoinLink,
|
||
ScheduledAt = scheduledAt,
|
||
Status = SessionStatus.Planned,
|
||
ThreadId = threadId,
|
||
sourceSession.MaxPlayers,
|
||
sourceSession.NotificationMode
|
||
},
|
||
transaction);
|
||
|
||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers));
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||
var batchMessage = await bot.SendMessage(
|
||
chatId: chatId,
|
||
messageThreadId: threadId,
|
||
text: renderResult.Text,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: renderResult.Markup);
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
|
||
new { MessageId = batchMessage.MessageId, BatchId = newBatchId });
|
||
|
||
return new WebSessionBatch(
|
||
newBatchId,
|
||
groupId,
|
||
batchTitle,
|
||
batchJoinLink,
|
||
renderedSessions.Min(session => session.ScheduledAt),
|
||
renderedSessions.Max(session => session.ScheduledAt),
|
||
renderedSessions.Count,
|
||
sourceSessions[0].NotificationMode);
|
||
}
|
||
|
||
public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebCampaignTemplate>(
|
||
"""
|
||
SELECT id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
FROM campaign_templates
|
||
WHERE group_id = @GroupId
|
||
ORDER BY created_at DESC, name
|
||
""",
|
||
new { GroupId = groupId })).ToList();
|
||
}
|
||
|
||
public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
|
||
"""
|
||
SELECT id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
FROM campaign_templates
|
||
WHERE id = @TemplateId
|
||
""",
|
||
new { TemplateId = templateId });
|
||
}
|
||
|
||
public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleAsync<WebCampaignTemplate>(
|
||
"""
|
||
INSERT INTO campaign_templates (
|
||
group_id,
|
||
name,
|
||
title,
|
||
join_link,
|
||
session_count,
|
||
interval_days,
|
||
max_players,
|
||
notification_mode
|
||
)
|
||
VALUES (
|
||
@GroupId,
|
||
@Name,
|
||
@Title,
|
||
@JoinLink,
|
||
@SessionCount,
|
||
@IntervalDays,
|
||
@MaxPlayers,
|
||
@NotificationMode
|
||
)
|
||
ON CONFLICT (group_id, name) DO UPDATE
|
||
SET title = EXCLUDED.title,
|
||
join_link = EXCLUDED.join_link,
|
||
session_count = EXCLUDED.session_count,
|
||
interval_days = EXCLUDED.interval_days,
|
||
max_players = EXCLUDED.max_players,
|
||
notification_mode = EXCLUDED.notification_mode,
|
||
updated_at = now()
|
||
RETURNING id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
request.Name,
|
||
request.Title,
|
||
request.JoinLink,
|
||
request.SessionCount,
|
||
request.IntervalDays,
|
||
request.MaxPlayers,
|
||
NotificationMode = request.NotificationMode.ToDatabaseValue()
|
||
});
|
||
}
|
||
|
||
public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
|
||
new { TemplateId = templateId, GroupId = groupId });
|
||
}
|
||
|
||
public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
|
||
"""
|
||
SELECT id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
FROM campaign_templates
|
||
WHERE id = @TemplateId
|
||
AND group_id = @GroupId
|
||
FOR UPDATE
|
||
""",
|
||
new { TemplateId = templateId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (template is null)
|
||
{
|
||
throw new SessionAccessDeniedException(templateId, 0);
|
||
}
|
||
|
||
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
||
"SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId",
|
||
new { GroupId = groupId },
|
||
transaction);
|
||
|
||
if (group is null)
|
||
{
|
||
throw new SessionAccessDeniedException(groupId, 0);
|
||
}
|
||
|
||
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
|
||
firstScheduledAt,
|
||
template.SessionCount,
|
||
template.IntervalDays);
|
||
var batchId = Guid.NewGuid();
|
||
var renderedSessions = new List<SessionBatchDto>();
|
||
|
||
foreach (var scheduledAt in schedule)
|
||
{
|
||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||
"""
|
||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode)
|
||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode)
|
||
RETURNING id
|
||
""",
|
||
new
|
||
{
|
||
BatchId = batchId,
|
||
GroupId = groupId,
|
||
template.Title,
|
||
template.JoinLink,
|
||
ScheduledAt = scheduledAt,
|
||
Status = SessionStatus.Planned,
|
||
template.MaxPlayers,
|
||
template.NotificationMode
|
||
},
|
||
transaction);
|
||
|
||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers));
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||
var batchMessage = await bot.SendMessage(
|
||
chatId: group.TelegramChatId,
|
||
text: renderResult.Text,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: renderResult.Markup);
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
|
||
new { MessageId = batchMessage.MessageId, BatchId = batchId });
|
||
|
||
return new WebSessionBatch(
|
||
batchId,
|
||
groupId,
|
||
template.Title,
|
||
template.JoinLink,
|
||
renderedSessions.Min(session => session.ScheduledAt),
|
||
renderedSessions.Max(session => session.ScheduledAt),
|
||
renderedSessions.Count,
|
||
template.NotificationMode);
|
||
}
|
||
|
||
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
|
||
Npgsql.NpgsqlConnection conn,
|
||
Guid sessionId)
|
||
{
|
||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||
"""
|
||
SELECT p.telegram_id AS TelegramId,
|
||
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 = @Active
|
||
""",
|
||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||
}
|
||
|
||
private async Task<List<WebDirectNotificationRecipient>> LoadBatchDirectRecipientsAsync(
|
||
Npgsql.NpgsqlConnection conn,
|
||
Guid batchId)
|
||
{
|
||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||
"""
|
||
SELECT DISTINCT p.telegram_id AS TelegramId,
|
||
p.display_name AS DisplayName
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
JOIN sessions s ON s.id = sp.session_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Active
|
||
""",
|
||
new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||
}
|
||
|
||
private async Task SendDirectNotificationsAsync(
|
||
IEnumerable<WebDirectNotificationRecipient> recipients,
|
||
string htmlText,
|
||
string notificationKind,
|
||
Guid entityId)
|
||
{
|
||
foreach (var recipient in recipients)
|
||
{
|
||
try
|
||
{
|
||
await bot.SendMessage(
|
||
chatId: recipient.TelegramId,
|
||
text: htmlText,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(
|
||
ex,
|
||
"Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})",
|
||
notificationKind,
|
||
entityId,
|
||
recipient.TelegramId,
|
||
recipient.DisplayName);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
|
||
{
|
||
try
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||
new { BatchId = batchId })).ToList();
|
||
|
||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||
@"SELECT sp.session_id AS SessionId,
|
||
p.display_name AS DisplayName,
|
||
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 = batchId })).ToList();
|
||
|
||
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
|
||
|
||
await bot.EditMessageText(
|
||
chatId: chatId,
|
||
messageId: messageId,
|
||
text: renderResult.Text,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: renderResult.Markup);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
||
}
|
||
}
|
||
|
||
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||
Npgsql.NpgsqlConnection conn,
|
||
Guid batchId,
|
||
Guid groupId,
|
||
Npgsql.NpgsqlTransaction transaction)
|
||
{
|
||
return await conn.QuerySingleOrDefaultAsync<WebBatchInfo>(
|
||
"""
|
||
SELECT s.batch_id AS BatchId,
|
||
s.group_id AS GroupId,
|
||
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
||
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
||
g.telegram_chat_id AS TelegramChatId,
|
||
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
|
||
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
|
||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND s.group_id = @GroupId
|
||
GROUP BY s.batch_id, s.group_id, g.telegram_chat_id
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId },
|
||
transaction);
|
||
}
|
||
}
|