feat(web): dashboard session deletion and E2E coverage for issue #150
Deploy Telegram Bot / build-and-push (push) Failing after 28m28s
Deploy Telegram Bot / scan-images (push) Has been skipped
Deploy Telegram Bot / deploy (push) Has been skipped

- Add DeleteSessionAsync to ISessionStore/SessionService (unpublish portfolio card,
  remove bot-created empty forum topic, update batch message).
- Add DeleteSessionForCurrentUserAsync to AuthorizedSessionService with audit log.
- Add delete button + confirmation dialog to GroupDetails.razor.
- Extend dashboard Playwright tests with edit persistence and delete verification.
- Update AuthorizedSessionServiceTests with delete authorization coverage.
- Mark issue #150 as done in tests/e2e/README.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 13:05:48 +03:00
parent 892f39401c
commit 40fc435bda
14 changed files with 1140 additions and 28 deletions
@@ -10,6 +10,7 @@
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@inject IJSRuntime JS
<PageTitle>Сессии группы — GM-Relay</PageTitle>
@@ -393,6 +394,9 @@
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline">📜 История</a>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(deletingSessionId == session.Id)" @onclick="() => DeleteSession(session.Id, session.Title)">
@(deletingSessionId == session.Id ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -491,6 +495,9 @@
✏️ Изменить
</a>
<a href="/session/@session.Id/history" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">📜 История</a>
<button type="button" class="btn-gm btn-gm-danger" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(deletingSessionId == session.Id)" @onclick="() => DeleteSession(session.Id, session.Title)">
@(deletingSessionId == session.Id ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
@if (CanPromote(session))
{
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
@@ -572,6 +579,7 @@
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
private Guid? loadingParticipantsSessionId;
private Guid? deletingSessionId;
protected override async Task OnInitializedAsync()
{
@@ -904,6 +912,40 @@
}
}
private async Task DeleteSession(Guid sessionId, string title)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", $"Удалить сессию «{title}»?");
if (!confirmed)
{
return;
}
errorMessage = null;
successMessage = null;
deletingSessionId = sessionId;
try
{
await SessionService.DeleteSessionForCurrentUserAsync(sessionId);
expandedSessions.Remove(sessionId);
participantsCache.Remove(sessionId);
successMessage = "Сессия удалена.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
deletingSessionId = null;
}
}
private static string FormatParticipantUsername(WebParticipant p)
{
var username = string.IsNullOrWhiteSpace(p.TelegramUsername)
@@ -229,6 +229,22 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
}
public async Task DeleteSessionForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var session = await GetSessionForCurrentUserAsync(sessionId);
if (session is null)
{
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
var title = await sessionStore.DeleteSessionAsync(sessionId, session.GroupId);
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Deleted", title, null);
}
public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId)
{
var identity = GetCurrentIdentity();
@@ -150,6 +150,7 @@ public interface ISessionStore
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
@@ -129,6 +129,14 @@ internal sealed record WebBatchSessionRow(
string? CoverImageUrl = null);
internal sealed record WebTemplateGroupDto(long TelegramChatId);
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
internal sealed record WebDeleteSessionInfo(
Guid Id,
string Title,
Guid BatchId,
long TelegramChatId,
int? BatchMessageId,
int? ThreadId,
bool TopicCreatedByBot);
internal sealed record WebPublicGroupRow(
Guid GroupId,
string Name,
@@ -1086,6 +1094,95 @@ public sealed class SessionService(
}
}
public async Task<string?> DeleteSessionAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
await using var transaction = await conn.BeginTransactionAsync();
var session = await conn.QuerySingleOrDefaultAsync<WebDeleteSessionInfo>(
"""
SELECT s.id AS Id,
s.title AS Title,
s.batch_id AS BatchId,
g.external_group_id::BIGINT AS TelegramChatId,
s.batch_message_id AS BatchMessageId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot
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");
}
await conn.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = @SessionId
AND pg.is_public = true
""",
new { SessionId = sessionId },
transaction);
await conn.ExecuteAsync(
"DELETE FROM sessions WHERE id = @Id AND group_id = @GroupId",
new { Id = sessionId, GroupId = groupId },
transaction);
var remainingInTopic = session.ThreadId.HasValue
? await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { GroupId = groupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync();
if (session.ThreadId.HasValue && session.TopicCreatedByBot && remainingInTopic == 0)
{
try
{
await bot.DeleteForumTopic(
chatId: session.TelegramChatId,
messageThreadId: session.ThreadId.Value);
logger.LogInformation(
"Deleted forum topic {ThreadId} for group {GroupId} as no sessions remained.",
session.ThreadId.Value,
groupId);
}
catch (Exception ex)
{
logger.LogWarning(
ex,
"Failed to delete forum topic {ThreadId} for group {GroupId}",
session.ThreadId.Value,
groupId);
}
}
if (session.BatchMessageId.HasValue)
{
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
}
return session.Title;
}
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();