Compare commits

..

2 Commits

Author SHA1 Message Date
Toutsu 9c1c6c2483 Merge pull request #51: feat(#19): добавить ссылку на игру в карточку батча
Deploy Telegram Bot / build-and-push (push) Successful in 4m12s
Deploy Telegram Bot / deploy (push) Successful in 13s
2026-05-10 18:18:50 +03:00
Toutsu c0c8f852d2 feat(#19): добавить ссылку на игру в карточку батча
PR Checks / test-and-build (pull_request) Successful in 3m49s
- SessionBatchDto: добавлено поле JoinLink
- SessionViewItem: добавлено поле JoinLink
- SessionBatchViewBuilder: прокидывание JoinLink из DTO в ViewModel
- CreateSessionHandler, SessionService: обновлены все вызовы конструктора
- TelegramSessionBatchRenderer (Bot + Web): рендеринг ссылки в карточке
- Добавлены тесты на наличие ссылки в рендере
- Все 7 SQL-запросов, загружающих SessionBatchDto, обновлены с join_link AS JoinLink
- Бамп версии до 1.11.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:13:55 +03:00
20 changed files with 54 additions and 32 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.10.6 VERSION: 1.11.0
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.10.6</Version> <Version>1.11.0</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+2 -2
View File
@@ -17,7 +17,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.6 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.11.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -30,7 +30,7 @@ services:
- gmrelay - gmrelay
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.6 image: git.codeanddice.ru/toutsu/gmrelay-web:1.11.0
restart: always restart: always
depends_on: depends_on:
db: db:
@@ -69,7 +69,7 @@ public sealed class CancelSessionHandler(
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -178,7 +178,7 @@ public sealed class CreateSessionHandler(
}, },
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers)); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
} }
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
@@ -115,7 +115,7 @@ public sealed class JoinSessionHandler(
// Загружаем весь батч для перерисовки // Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers @"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at", ORDER BY scheduled_at",
@@ -157,7 +157,8 @@ public sealed class LeaveSessionHandler(
SELECT id AS SessionId, SELECT id AS SessionId,
scheduled_at AS ScheduledAt, scheduled_at AS ScheduledAt,
status AS Status, status AS Status,
max_players AS MaxPlayers max_players AS MaxPlayers,
join_link AS JoinLink
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -137,7 +137,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
SELECT id AS SessionId, SELECT id AS SessionId,
scheduled_at AS ScheduledAt, scheduled_at AS ScheduledAt,
status AS Status, status AS Status,
max_players AS MaxPlayers max_players AS MaxPlayers,
join_link AS JoinLink
FROM sessions FROM sessions
WHERE batch_id = @BatchId WHERE batch_id = @BatchId
ORDER BY scheduled_at ORDER BY scheduled_at
@@ -357,7 +357,7 @@ public sealed class HandleRescheduleTimeInputHandler(
await using var conn = await dataSource.OpenConnectionAsync(ct); await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>( var batchSessions = (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", "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 { proposal.BatchId })).ToList(); new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -286,7 +286,7 @@ public sealed class RescheduleVotingDeadlineService(
await using var connection = await dataSource.OpenConnectionAsync(ct); await using var connection = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>( var batchSessions = (await connection.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", "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 { proposal.BatchId })).ToList(); new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>( var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
@@ -21,6 +21,11 @@ public static class TelegramSessionBatchRenderer
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n" ? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n"; : $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
}
if (session.ActivePlayers.Count > 0) if (session.ActivePlayers.Count > 0)
{ {
messageText += string.Join("\n", session.ActivePlayers.Select(p => messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -1,4 +1,4 @@
namespace GmRelay.Shared.Rendering; namespace GmRelay.Shared.Rendering;
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers); public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus); public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
@@ -38,6 +38,7 @@ public static class SessionBatchViewBuilder
session.ScheduledAt, session.ScheduledAt,
session.Status, session.Status,
session.MaxPlayers, session.MaxPlayers,
session.JoinLink,
activePlayers.Count, activePlayers.Count,
activePlayers, activePlayers,
waitlistedPlayers, waitlistedPlayers,
@@ -11,6 +11,7 @@ public sealed record SessionViewItem(
DateTime ScheduledAt, DateTime ScheduledAt,
string Status, string Status,
int? MaxPlayers, int? MaxPlayers,
string JoinLink,
int ActivePlayerCount, int ActivePlayerCount,
IReadOnlyList<PlayerViewItem> ActivePlayers, IReadOnlyList<PlayerViewItem> ActivePlayers,
IReadOnlyList<PlayerViewItem> WaitlistedPlayers, IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
@@ -56,7 +56,7 @@
</button> </button>
</form> </form>
<div class="nav-version">v1.10.6</div> <div class="nav-version">v1.11.0</div>
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
+3 -3
View File
@@ -938,7 +938,7 @@ public sealed class SessionService(
}, },
transaction); transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers)); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
@@ -1147,7 +1147,7 @@ public sealed class SessionService(
}, },
transaction); transaction);
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers)); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
} }
await transaction.CommitAsync(); await transaction.CommitAsync();
@@ -1245,7 +1245,7 @@ public sealed class SessionService(
await using var conn = await dataSource.OpenConnectionAsync(); await using var conn = await dataSource.OpenConnectionAsync();
var sessions = (await conn.QueryAsync<SessionBatchDto>( 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", "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 = batchId })).ToList(); new { BatchId = batchId })).ToList();
var participants = (await conn.QueryAsync<ParticipantBatchDto>( var participants = (await conn.QueryAsync<ParticipantBatchDto>(
@@ -20,6 +20,11 @@ public static class TelegramSessionBatchRenderer
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n" ? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
: $"👥 Игроки ({session.ActivePlayerCount}):\n"; : $"👥 Игроки ({session.ActivePlayerCount}):\n";
if (!string.IsNullOrEmpty(session.JoinLink))
{
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
}
if (session.ActivePlayers.Count > 0) if (session.ActivePlayers.Count > 0)
{ {
messageText += string.Join("\n", session.ActivePlayers.Select(p => messageText += string.Join("\n", session.ActivePlayers.Select(p =>
@@ -147,6 +147,7 @@ public sealed class TelegramLandingPromisesSmokeTests
string title, string title,
IReadOnlyList<DateTimeOffset> scheduledTimes, IReadOnlyList<DateTimeOffset> scheduledTimes,
int? maxPlayers, int? maxPlayers,
string joinLink,
SessionNotificationMode notificationMode) SessionNotificationMode notificationMode)
{ {
Title = title; Title = title;
@@ -156,7 +157,8 @@ public sealed class TelegramLandingPromisesSmokeTests
Guid.NewGuid(), Guid.NewGuid(),
scheduledAt.UtcDateTime, scheduledAt.UtcDateTime,
SessionStatus.Planned, SessionStatus.Planned,
maxPlayers)) maxPlayers,
joinLink))
.ToList(); .ToList();
} }
@@ -173,6 +175,7 @@ public sealed class TelegramLandingPromisesSmokeTests
parseResult.Title!, parseResult.Title!,
parseResult.ScheduledTimes, parseResult.ScheduledTimes,
parseResult.MaxPlayers, parseResult.MaxPlayers,
parseResult.Link!,
notificationMode); notificationMode);
scenario.RenderBatch(); scenario.RenderBatch();
@@ -318,7 +321,8 @@ public sealed class TelegramLandingPromisesSmokeTests
session.Id, session.Id,
session.ScheduledAt, session.ScheduledAt,
session.Status, session.Status,
session.MaxPlayers)) session.MaxPlayers,
session.JoinLink))
.ToList(), .ToList(),
participants participants
.Select(participant => new ParticipantBatchDto( .Select(participant => new ParticipantBatchDto(
@@ -371,7 +375,8 @@ public sealed class TelegramLandingPromisesSmokeTests
Guid Id, Guid Id,
DateTime ScheduledAt, DateTime ScheduledAt,
string Status, string Status,
int? MaxPlayers) int? MaxPlayers,
string JoinLink)
{ {
public DateTime ScheduledAt { get; set; } = ScheduledAt; public DateTime ScheduledAt { get; set; } = ScheduledAt;
} }
@@ -14,9 +14,9 @@ public sealed class SessionBatchViewBuilderTests
var sessions = new[] var sessions = new[]
{ {
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4), new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game"),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null), new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2) new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game")
}; };
var participants = new[] var participants = new[]
{ {
@@ -38,7 +38,7 @@ public sealed class SessionBatchViewBuilderTests
public void Build_ShouldCalculatePlayerCounts() public void Build_ShouldCalculatePlayerCounts()
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) }; var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
var participants = new[] var participants = new[]
{ {
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active), new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
@@ -59,7 +59,7 @@ public sealed class SessionBatchViewBuilderTests
public void Build_ShouldIncludeActionsForActiveSessions() public void Build_ShouldIncludeActionsForActiveSessions()
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) }; var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
var participants = Array.Empty<ParticipantBatchDto>(); var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants); var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -76,7 +76,7 @@ public sealed class SessionBatchViewBuilderTests
public void Build_ShouldNotIncludeActionsForCancelledSessions() public void Build_ShouldNotIncludeActionsForCancelledSessions()
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) }; var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") };
var participants = Array.Empty<ParticipantBatchDto>(); var participants = Array.Empty<ParticipantBatchDto>();
var result = SessionBatchViewBuilder.Build("Test", sessions, participants); var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -88,7 +88,7 @@ public sealed class SessionBatchViewBuilderTests
public void Build_ShouldMarkWaitlistActionWhenFull() public void Build_ShouldMarkWaitlistActionWhenFull()
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) }; var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var result = SessionBatchViewBuilder.Build("Test", sessions, participants); var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -101,7 +101,7 @@ public sealed class SessionBatchViewBuilderTests
public void Build_ShouldIncludePlayerUsernames() public void Build_ShouldIncludePlayerUsernames()
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) }; var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null, "https://example.com/game") };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var result = SessionBatchViewBuilder.Build("Test", sessions, participants); var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests
var sessions = new[] var sessions = new[]
{ {
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4), new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null), new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2) new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1")
}; };
var participants = new[] var participants = new[]
{ {
@@ -35,6 +35,9 @@ public sealed class TelegramSessionBatchRendererTests
Assert.Contains("Charlie", text); Assert.Contains("Charlie", text);
Assert.Contains("Bob", text); Assert.Contains("Bob", text);
Assert.Contains("Сессия отменена", text); Assert.Contains("Сессия отменена", text);
Assert.Contains("Ссылка на игру", text);
Assert.Contains("https://example.com/game1", text);
Assert.Contains("https://example.com/game2", text);
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList(); var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
@@ -51,7 +54,7 @@ public sealed class TelegramSessionBatchRendererTests
public void Render_ShouldSkipButtonsForCancelledSessions() public void Render_ShouldSkipButtonsForCancelledSessions()
{ {
var cancelledSessionId = Guid.NewGuid(); var cancelledSessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) }; var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") };
var participants = Array.Empty<ParticipantBatchDto>(); var participants = Array.Empty<ParticipantBatchDto>();
var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
@@ -64,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
public void Render_ShouldShowWaitlistButtonWhenFull() public void Render_ShouldShowWaitlistButtonWhenFull()
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) }; var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) }; var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
var view = SessionBatchViewBuilder.Build("Test", sessions, participants); var view = SessionBatchViewBuilder.Build("Test", sessions, participants);