feat(web): dashboard session deletion and E2E coverage for issue #150
- 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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user