From cde1e4311fe9f5f1aacdcfe793a80185f851a38b Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 14:46:04 +0300 Subject: [PATCH 01/17] feat(db): V027 add showcase fields to sessions --- .../Migrations/V027__add_showcase_fields.sql | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql diff --git a/src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql b/src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql new file mode 100644 index 0000000..d49d618 --- /dev/null +++ b/src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql @@ -0,0 +1,14 @@ +-- Showcase fields for game catalog / public session browsing. + +ALTER TABLE sessions + ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN system VARCHAR(50), + ADD COLUMN description TEXT, + ADD COLUMN cover_image_url TEXT, + ADD COLUMN duration_minutes INTEGER, + ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')), + ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX ix_sessions_showcase + ON sessions (scheduled_at, system, is_one_shot, format) + WHERE is_public = true AND status <> 'Cancelled'; From f94bea3e742bf4eceeba096664106d50e41cfdb0 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 14:58:49 +0300 Subject: [PATCH 02/17] feat(shared): add GameSystem enum and Showcase DTOs Co-Authored-By: Claude Opus 4.7 --- src/GmRelay.Shared/Domain/GameSystem.cs | 88 +++++++++++++++++++ .../Features/Showcase/ShowcaseFilter.cs | 23 +++++ .../Features/Showcase/ShowcaseSessionDto.cs | 19 ++++ 3 files changed, 130 insertions(+) create mode 100644 src/GmRelay.Shared/Domain/GameSystem.cs create mode 100644 src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs create mode 100644 src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs diff --git a/src/GmRelay.Shared/Domain/GameSystem.cs b/src/GmRelay.Shared/Domain/GameSystem.cs new file mode 100644 index 0000000..639f757 --- /dev/null +++ b/src/GmRelay.Shared/Domain/GameSystem.cs @@ -0,0 +1,88 @@ +using System.Collections.Frozen; + +namespace GmRelay.Shared.Domain; + +public enum GameSystem +{ + Dnd5e, + Dnd3_5, + Dnd4e, + DnD2024, + Pathfinder1e, + Pathfinder2e, + Starfinder, + CallOfCthulhu, + WarhammerFantasy, + Warhammer40k, + DarkHeresy, + Shadowrun, + GURPS, + Fate, + SavageWorlds, + CyberpunkRed, + VampireTheMasquerade, + Lancer, + BladesInTheDark, + DungeonWorld, + Numenera, + CypherSystem, + Other +} + +public static class GameSystemExtensions +{ + private static readonly FrozenDictionary DisplayNames = + new Dictionary + { + [GameSystem.Dnd5e] = "D&D 5e", + [GameSystem.Dnd3_5] = "D&D 3.5", + [GameSystem.Dnd4e] = "D&D 4e", + [GameSystem.DnD2024] = "D&D 2024", + [GameSystem.Pathfinder1e] = "Pathfinder 1e", + [GameSystem.Pathfinder2e] = "Pathfinder 2e", + [GameSystem.Starfinder] = "Starfinder", + [GameSystem.CallOfCthulhu] = "Call of Cthulhu", + [GameSystem.WarhammerFantasy] = "Warhammer Fantasy", + [GameSystem.Warhammer40k] = "Warhammer 40,000", + [GameSystem.DarkHeresy] = "Dark Heresy", + [GameSystem.Shadowrun] = "Shadowrun", + [GameSystem.GURPS] = "GURPS", + [GameSystem.Fate] = "Fate", + [GameSystem.SavageWorlds] = "Savage Worlds", + [GameSystem.CyberpunkRed] = "Cyberpunk Red", + [GameSystem.VampireTheMasquerade] = "Vampire: The Masquerade", + [GameSystem.Lancer] = "Lancer", + [GameSystem.BladesInTheDark] = "Blades in the Dark", + [GameSystem.DungeonWorld] = "Dungeon World", + [GameSystem.Numenera] = "Numenera", + [GameSystem.CypherSystem] = "Cypher System", + [GameSystem.Other] = "Другое" + }.ToFrozenDictionary(); + + public static string ToDisplayName(this GameSystem system) => + DisplayNames.TryGetValue(system, out var name) ? name : "Другое"; + + public static GameSystem TryParseFuzzy(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return GameSystem.Other; + + if (Enum.TryParse(input, true, out var exact)) + return exact; + + foreach (var system in Enum.GetValues()) + { + if (system == GameSystem.Other) + continue; + + var name = system.ToString(); + if (name.Contains(input, StringComparison.OrdinalIgnoreCase) || + input.Contains(name, StringComparison.OrdinalIgnoreCase)) + { + return system; + } + } + + return GameSystem.Other; + } +} diff --git a/src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs b/src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs new file mode 100644 index 0000000..b71c61e --- /dev/null +++ b/src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs @@ -0,0 +1,23 @@ +namespace GmRelay.Shared.Features.Showcase; + +public sealed record ShowcaseFilter( + DateFilter Date = DateFilter.All, + SeatFilter Seats = SeatFilter.Any, + string? System = null, + bool? IsOneShot = null, + string? Format = null); + +public enum DateFilter +{ + Today, + Tomorrow, + ThisWeek, + All +} + +public enum SeatFilter +{ + Available, + Waitlist, + Any +} diff --git a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs new file mode 100644 index 0000000..3c0e13e --- /dev/null +++ b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs @@ -0,0 +1,19 @@ +namespace GmRelay.Shared.Features.Showcase; + +public sealed record ShowcaseSessionDto( + Guid Id, + Guid GroupId, + string GroupName, + string? GroupSlug, + string Title, + DateTime ScheduledAt, + string Status, + string? System, + bool IsOneShot, + string? Format, + int? DurationMinutes, + string? CoverImageUrl, + int? MaxPlayers, + int ActivePlayerCount, + int WaitlistedPlayerCount, + bool AllowDirectRegistration); From 9b42ea034ad9cbbd952ce562d524f316f4642f3d Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 15:06:39 +0300 Subject: [PATCH 03/17] fix(shared): align GameSystem enum with spec, make TryParseFuzzy nullable and display-name based --- src/GmRelay.Shared/Domain/GameSystem.cs | 81 ++++++++++++------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/src/GmRelay.Shared/Domain/GameSystem.cs b/src/GmRelay.Shared/Domain/GameSystem.cs index 639f757..eb14d9a 100644 --- a/src/GmRelay.Shared/Domain/GameSystem.cs +++ b/src/GmRelay.Shared/Domain/GameSystem.cs @@ -5,27 +5,25 @@ namespace GmRelay.Shared.Domain; public enum GameSystem { Dnd5e, - Dnd3_5, - Dnd4e, - DnD2024, - Pathfinder1e, Pathfinder2e, - Starfinder, - CallOfCthulhu, + CallOfCthulhu7e, + Shadowdark, + OldSchoolEssentials, + Dragonbane, + BladesInTheDark, + Daggerheart, + CyberpunkRed, + Mothership, + AlienRpg, WarhammerFantasy, - Warhammer40k, - DarkHeresy, - Shadowrun, + VampireMasquerade5e, + StarWarsFfg, + Genesys, + SavageWorlds, GURPS, Fate, - SavageWorlds, - CyberpunkRed, - VampireTheMasquerade, - Lancer, - BladesInTheDark, DungeonWorld, - Numenera, - CypherSystem, + Ironsworn, Other } @@ -35,52 +33,49 @@ public static class GameSystemExtensions new Dictionary { [GameSystem.Dnd5e] = "D&D 5e", - [GameSystem.Dnd3_5] = "D&D 3.5", - [GameSystem.Dnd4e] = "D&D 4e", - [GameSystem.DnD2024] = "D&D 2024", - [GameSystem.Pathfinder1e] = "Pathfinder 1e", [GameSystem.Pathfinder2e] = "Pathfinder 2e", - [GameSystem.Starfinder] = "Starfinder", - [GameSystem.CallOfCthulhu] = "Call of Cthulhu", + [GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e", + [GameSystem.Shadowdark] = "Shadowdark", + [GameSystem.OldSchoolEssentials] = "Old School Essentials", + [GameSystem.Dragonbane] = "Dragonbane", + [GameSystem.BladesInTheDark] = "Blades in the Dark", + [GameSystem.Daggerheart] = "Daggerheart", + [GameSystem.CyberpunkRed] = "Cyberpunk RED", + [GameSystem.Mothership] = "Mothership", + [GameSystem.AlienRpg] = "Alien RPG", [GameSystem.WarhammerFantasy] = "Warhammer Fantasy", - [GameSystem.Warhammer40k] = "Warhammer 40,000", - [GameSystem.DarkHeresy] = "Dark Heresy", - [GameSystem.Shadowrun] = "Shadowrun", + [GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e", + [GameSystem.StarWarsFfg] = "Star Wars (FFG)", + [GameSystem.Genesys] = "Genesys", + [GameSystem.SavageWorlds] = "Savage Worlds", [GameSystem.GURPS] = "GURPS", [GameSystem.Fate] = "Fate", - [GameSystem.SavageWorlds] = "Savage Worlds", - [GameSystem.CyberpunkRed] = "Cyberpunk Red", - [GameSystem.VampireTheMasquerade] = "Vampire: The Masquerade", - [GameSystem.Lancer] = "Lancer", - [GameSystem.BladesInTheDark] = "Blades in the Dark", [GameSystem.DungeonWorld] = "Dungeon World", - [GameSystem.Numenera] = "Numenera", - [GameSystem.CypherSystem] = "Cypher System", + [GameSystem.Ironsworn] = "Ironsworn", [GameSystem.Other] = "Другое" }.ToFrozenDictionary(); public static string ToDisplayName(this GameSystem system) => DisplayNames.TryGetValue(system, out var name) ? name : "Другое"; - public static GameSystem TryParseFuzzy(string input) + public static GameSystem? TryParseFuzzy(string input) { if (string.IsNullOrWhiteSpace(input)) - return GameSystem.Other; + return null; - if (Enum.TryParse(input, true, out var exact)) + var normalized = input.Trim().ToLowerInvariant(); + + if (Enum.TryParse(normalized, true, out var exact)) return exact; - foreach (var system in Enum.GetValues()) + foreach (var value in Enum.GetValues()) { - if (system == GameSystem.Other) + if (value == GameSystem.Other) continue; - var name = system.ToString(); - if (name.Contains(input, StringComparison.OrdinalIgnoreCase) || - input.Contains(name, StringComparison.OrdinalIgnoreCase)) - { - return system; - } + var display = value.ToDisplayName().ToLowerInvariant(); + if (display == normalized || display.Contains(normalized) || normalized.Contains(display)) + return value; } return GameSystem.Other; From b2497ed877c0578ece13e29722b03cf922059fa3 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 15:27:18 +0300 Subject: [PATCH 04/17] feat(web): add showcase query and registration methods --- src/GmRelay.Web/Services/ISessionStore.cs | 6 + src/GmRelay.Web/Services/SessionService.cs | 242 +++++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/src/GmRelay.Web/Services/ISessionStore.cs b/src/GmRelay.Web/Services/ISessionStore.cs index 979d8f5..79c9548 100644 --- a/src/GmRelay.Web/Services/ISessionStore.cs +++ b/src/GmRelay.Web/Services/ISessionStore.cs @@ -1,4 +1,5 @@ using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Showcase; namespace GmRelay.Web.Services; @@ -91,6 +92,11 @@ public interface ISessionStore Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName); Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId); Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl); + + // --- Showcase / game catalog (issue #39) --- + Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize); + Task GetShowcaseSessionAsync(Guid sessionId); + Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName); } public sealed record LinkedIdentity( diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 5470d4e..158fc59 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -1,5 +1,6 @@ using Dapper; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Showcase; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; @@ -110,6 +111,23 @@ internal sealed record WebBatchSessionRow( internal sealed record WebTemplateGroupDto(long TelegramChatId); internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot); internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug); +internal sealed record ShowcaseSessionRow( + Guid Id, + Guid GroupId, + string GroupName, + string? GroupSlug, + string Title, + DateTime ScheduledAt, + string Status, + string? System, + bool IsOneShot, + string? Format, + int? DurationMinutes, + string? CoverImageUrl, + int? MaxPlayers, + int ActivePlayerCount, + int WaitlistedPlayerCount, + bool AllowDirectRegistration); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -362,6 +380,230 @@ public sealed class SessionService( }); } + public async Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var rows = await conn.QueryAsync( + """ + SELECT s.id AS Id, + s.group_id AS GroupId, + COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName, + g.public_slug AS GroupSlug, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + s.status AS Status, + s.system AS System, + s.is_one_shot AS IsOneShot, + s.format AS Format, + s.duration_minutes AS DurationMinutes, + s.cover_image_url AS CoverImageUrl, + s.max_players AS MaxPlayers, + COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, + s.allow_direct_registration AS AllowDirectRegistration + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + LEFT JOIN LATERAL ( + SELECT recent.title + FROM sessions recent + WHERE recent.group_id = g.id + ORDER BY recent.scheduled_at DESC + LIMIT 1 + ) latest_session 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 = @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 g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND s.is_public = true + AND s.scheduled_at > now() - interval '4 hours' + AND s.status <> @Cancelled + AND ( + @DateFilter = 'All' + OR (@DateFilter = 'Today' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '1 day') + OR (@DateFilter = 'Tomorrow' AND s.scheduled_at >= CURRENT_DATE + interval '1 day' AND s.scheduled_at < CURRENT_DATE + interval '2 days') + OR (@DateFilter = 'ThisWeek' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '7 days') + ) + AND ( + @SeatFilter = 'Any' + OR (@SeatFilter = 'Available' AND (s.max_players IS NULL OR active_counts.count < s.max_players)) + OR (@SeatFilter = 'Waitlist' AND (s.max_players IS NOT NULL AND active_counts.count >= s.max_players)) + ) + AND (@System IS NULL OR s.system = @System) + AND (@IsOneShot IS NULL OR s.is_one_shot = @IsOneShot) + AND (@Format IS NULL OR s.format = @Format) + ORDER BY s.scheduled_at ASC + LIMIT @PageSize OFFSET @Offset + """, + new + { + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted, + Cancelled = SessionStatus.Cancelled, + DateFilter = filter.Date.ToString(), + SeatFilter = filter.Seats.ToString(), + filter.System, + filter.IsOneShot, + filter.Format, + PageSize = pageSize, + Offset = (page - 1) * pageSize + }); + + return rows.Select(r => new ShowcaseSessionDto( + r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status, + r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl, + r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration)).ToList(); + } + + public async Task GetShowcaseSessionAsync(Guid sessionId) + { + await using var conn = await dataSource.OpenConnectionAsync(); + var row = await conn.QuerySingleOrDefaultAsync( + """ + SELECT s.id AS Id, + s.group_id AS GroupId, + COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName, + g.public_slug AS GroupSlug, + s.title AS Title, + s.scheduled_at AS ScheduledAt, + s.status AS Status, + s.system AS System, + s.is_one_shot AS IsOneShot, + s.format AS Format, + s.duration_minutes AS DurationMinutes, + s.cover_image_url AS CoverImageUrl, + s.max_players AS MaxPlayers, + COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, + COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, + s.allow_direct_registration AS AllowDirectRegistration + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + LEFT JOIN LATERAL ( + SELECT recent.title + FROM sessions recent + WHERE recent.group_id = g.id + ORDER BY recent.scheduled_at DESC + LIMIT 1 + ) latest_session 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 = @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 + AND g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND s.is_public = true + AND s.scheduled_at > now() - interval '4 hours' + AND s.status <> @Cancelled + """, + new + { + SessionId = sessionId, + Active = ParticipantRegistrationStatus.Active, + Waitlisted = ParticipantRegistrationStatus.Waitlisted, + Cancelled = SessionStatus.Cancelled + }); + + if (row is null) + return null; + + return new ShowcaseSessionDto( + row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status, + row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl, + row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration); + } + + public async Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) + { + await using var conn = await dataSource.OpenConnectionAsync(); + await using var transaction = await conn.BeginTransactionAsync(); + + var session = await conn.QuerySingleOrDefaultAsync( + """ + SELECT id, max_players AS MaxPlayers, allow_direct_registration AS AllowDirectRegistration + FROM sessions + WHERE id = @SessionId + FOR UPDATE + """, + new { SessionId = sessionId }, + transaction); + + if (session is null || !(bool)session.allowdirectregistration) + { + await transaction.RollbackAsync(); + return false; + } + + var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction); + + var existing = await conn.ExecuteScalarAsync( + """ + SELECT EXISTS ( + SELECT 1 FROM session_participants + WHERE session_id = @SessionId AND player_id = @PlayerId + ) + """, + new { SessionId = sessionId, PlayerId = playerId }, + transaction); + + if (existing) + { + await transaction.RollbackAsync(); + return false; + } + + var activeCount = await conn.ExecuteScalarAsync( + """ + 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 registrationStatus = SessionCapacityRules.DecideJoinStatus((int?)session.maxplayers, activeCount); + + await conn.ExecuteAsync( + """ + INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status) + VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus) + """, + new + { + SessionId = sessionId, + PlayerId = playerId, + Pending = RsvpStatus.Pending, + RegistrationStatus = registrationStatus + }, + transaction); + + await transaction.CommitAsync(); + return true; + } + public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); From a5f4a68c6a28fd73aa88936ff01dcc0c8c56fbe0 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 15:40:21 +0300 Subject: [PATCH 05/17] fix(web): add public-session guards and ON CONFLICT to RegisterFromShowcaseAsync --- src/GmRelay.Web/Services/SessionService.cs | 62 ++++++++++------------ 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 158fc59..815512f 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -541,12 +541,18 @@ public sealed class SessionService( var session = await conn.QuerySingleOrDefaultAsync( """ - SELECT id, max_players AS MaxPlayers, allow_direct_registration AS AllowDirectRegistration - FROM sessions - WHERE id = @SessionId - FOR UPDATE + SELECT s.id, s.max_players AS MaxPlayers, s.allow_direct_registration AS AllowDirectRegistration + FROM sessions s + JOIN game_groups g ON g.id = s.group_id + WHERE s.id = @SessionId + AND s.is_public = true + AND g.public_schedule_enabled = true + AND g.public_slug IS NOT NULL + AND s.scheduled_at > now() - interval '4 hours' + AND s.status <> @Cancelled + FOR UPDATE OF s """, - new { SessionId = sessionId }, + new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled }, transaction); if (session is null || !(bool)session.allowdirectregistration) @@ -557,39 +563,21 @@ public sealed class SessionService( var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction); - var existing = await conn.ExecuteScalarAsync( - """ - SELECT EXISTS ( - SELECT 1 FROM session_participants - WHERE session_id = @SessionId AND player_id = @PlayerId - ) - """, - new { SessionId = sessionId, PlayerId = playerId }, - transaction); + var registrationStatus = SessionCapacityRules.DecideJoinStatus( + (int?)session.maxplayers, + await conn.ExecuteScalarAsync( + """ + SELECT COUNT(*) FROM session_participants + WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active + """, + new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, + transaction)); - if (existing) - { - await transaction.RollbackAsync(); - return false; - } - - var activeCount = await conn.ExecuteScalarAsync( - """ - 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 registrationStatus = SessionCapacityRules.DecideJoinStatus((int?)session.maxplayers, activeCount); - - await conn.ExecuteAsync( + var inserted = await conn.ExecuteAsync( """ INSERT INTO session_participants (session_id, player_id, is_gm, rsvp_status, registration_status) VALUES (@SessionId, @PlayerId, false, @Pending, @RegistrationStatus) + ON CONFLICT (session_id, player_id) DO NOTHING """, new { @@ -600,6 +588,12 @@ public sealed class SessionService( }, transaction); + if (inserted == 0) + { + await transaction.RollbackAsync(); + return false; + } + await transaction.CommitAsync(); return true; } From 72f43dbef2b03d14290fc92da11a79f4aa189bb0 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:00:21 +0300 Subject: [PATCH 06/17] feat(web): add /showcase catalog page with filters Co-Authored-By: Claude Opus 4.7 --- .../Components/Pages/Showcase.razor | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 src/GmRelay.Web/Components/Pages/Showcase.razor diff --git a/src/GmRelay.Web/Components/Pages/Showcase.razor b/src/GmRelay.Web/Components/Pages/Showcase.razor new file mode 100644 index 0000000..7acdddc --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/Showcase.razor @@ -0,0 +1,423 @@ +@page "/showcase" +@layout PublicLayout +@inject ISessionStore SessionStore +@inject NavigationManager Navigation +@using GmRelay.Shared.Features.Showcase + +Каталог игр — GM-Relay + + + + + +
+

Каталог игр

+

Найдите настольную ролевую игру по душе — ваншоты, кампании, онлайн и офлайн.

+
+ +
+
+ Когда +
+ + + + +
+
+ +
+ Места +
+ + + +
+
+ +
+ Система + +
+ +
+ Тип +
+ + + +
+
+ +
+ Формат +
+ + + + +
+
+
+ +@if (loading && sessions.Count == 0) +{ +
+ @for (var i = 0; i < 6; i++) + { +
+
+
+
+
+
+
+
+ } +
+} +else if (!loading && sessions.Count == 0) +{ +
+

Игры не найдены

+

Попробуйте изменить фильтры или загляните позже — новые сессии появляются каждый день.

+
+} +else +{ +
+ @foreach (var session in sessions) + { +
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(session.System)) + { + @GetSystemDisplayName(session.System) + } + @if (session.IsOneShot) + { + Ваншот + } + @if (!string.IsNullOrWhiteSpace(session.Format)) + { + @TranslateFormat(session.Format) + } +
+

@session.Title

+
+ @session.ScheduledAt.FormatMoscow() + @if (session.DurationMinutes.HasValue) + { + @FormatDuration(session.DurationMinutes.Value) + } +
+
+ @FormatSeats(session) +
+
+ @session.GroupName +
+
+ Подробнее + @if (session.AllowDirectRegistration) + { + + } +
+
+
+ } +
+ + @if (hasMore) + { +
+ +
+ } +} + + + +@code { + private ShowcaseFilter filter = new(); + private List sessions = new(); + private bool loading; + private bool hasMore; + private int page = 1; + private const int PageSize = 12; + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + page = 1; + sessions.Clear(); + var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); + sessions.AddRange(results); + hasMore = results.Count == PageSize; + loading = false; + } + + private async Task LoadMoreAsync() + { + loading = true; + page++; + var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); + sessions.AddRange(results); + hasMore = results.Count == PageSize; + loading = false; + } + + private async Task OnFilterChanged() + { + await LoadAsync(); + } + + private async Task SetDate(DateFilter value) + { + filter = filter with { Date = value }; + await OnFilterChanged(); + } + + private async Task SetSeats(SeatFilter value) + { + filter = filter with { Seats = value }; + await OnFilterChanged(); + } + + private async Task OnSystemChanged(ChangeEventArgs e) + { + var value = e.Value?.ToString(); + filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value }; + await OnFilterChanged(); + } + + private async Task SetOneShot(bool? value) + { + filter = filter with { IsOneShot = value }; + await OnFilterChanged(); + } + + private async Task SetFormat(string? value) + { + filter = filter with { Format = value }; + await OnFilterChanged(); + } + + private static string GetGradientStyle(Guid id) + { + var bytes = id.ToByteArray(); + var hue1 = bytes[0] % 360; + var hue2 = (bytes[1] + 120) % 360; + return $"linear-gradient(135deg, hsl({hue1}, 55%, 28%) 0%, hsl({hue2}, 55%, 20%) 100%)"; + } + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string FormatSeats(ShowcaseSessionDto session) + { + var seats = session.MaxPlayers.HasValue + ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" + : $"{session.ActivePlayerCount} игроков"; + + if (session.WaitlistedPlayerCount > 0) + seats += $", ожидание {session.WaitlistedPlayerCount}"; + + return seats; + } + + private static string FormatDuration(int minutes) + { + if (minutes < 60) + return $"{minutes} мин"; + + var hours = minutes / 60; + var mins = minutes % 60; + return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч"; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; +} From 71ffcce06bc4c81ad58f86e5c57698fcc77f9d43 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:07:09 +0300 Subject: [PATCH 07/17] fix(web): add try/finally, concurrency guard, accessible label, registration link to showcase --- .../Components/Pages/Showcase.razor | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/GmRelay.Web/Components/Pages/Showcase.razor b/src/GmRelay.Web/Components/Pages/Showcase.razor index 7acdddc..c33b485 100644 --- a/src/GmRelay.Web/Components/Pages/Showcase.razor +++ b/src/GmRelay.Web/Components/Pages/Showcase.razor @@ -36,8 +36,8 @@
- Система - @foreach (var system in Enum.GetValues()) { @@ -134,7 +134,7 @@ else Подробнее @if (session.AllowDirectRegistration) { - + Записаться }
@@ -318,22 +318,37 @@ else private async Task LoadAsync() { loading = true; - page = 1; - sessions.Clear(); - var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); - sessions.AddRange(results); - hasMore = results.Count == PageSize; - loading = false; + try + { + page = 1; + sessions.Clear(); + var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); + sessions.AddRange(results); + hasMore = results.Count == PageSize; + } + finally + { + loading = false; + } } private async Task LoadMoreAsync() { + if (loading) + return; + loading = true; - page++; - var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); - sessions.AddRange(results); - hasMore = results.Count == PageSize; - loading = false; + try + { + page++; + var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); + sessions.AddRange(results); + hasMore = results.Count == PageSize; + } + finally + { + loading = false; + } } private async Task OnFilterChanged() From 6d59737d075f09e961ddea8615f2fa20c3ec37d9 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:13:35 +0300 Subject: [PATCH 08/17] feat(web): update public session detail with showcase fields Co-Authored-By: Claude Opus 4.7 --- .../Features/Showcase/ShowcaseSessionDto.cs | 3 +- .../Components/Pages/PublicSession.razor | 74 ++++++++++++++++++- src/GmRelay.Web/Services/SessionService.cs | 15 ++-- 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs index 3c0e13e..4ff58c8 100644 --- a/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs +++ b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs @@ -16,4 +16,5 @@ public sealed record ShowcaseSessionDto( int? MaxPlayers, int ActivePlayerCount, int WaitlistedPlayerCount, - bool AllowDirectRegistration); + bool AllowDirectRegistration, + string? Description); diff --git a/src/GmRelay.Web/Components/Pages/PublicSession.razor b/src/GmRelay.Web/Components/Pages/PublicSession.razor index 3abbe13..0fc2fae 100644 --- a/src/GmRelay.Web/Components/Pages/PublicSession.razor +++ b/src/GmRelay.Web/Components/Pages/PublicSession.razor @@ -2,6 +2,7 @@ @layout PublicLayout @inject ISessionStore SessionStore @inject NavigationManager Navigation +@using GmRelay.Shared.Features.Showcase @PageTitleText @@ -30,10 +31,29 @@ else if (session is not null) + @if (!string.IsNullOrWhiteSpace(session.CoverImageUrl)) + { +
+ } +
@TranslateStatus(session.Status)

@session.Title

@session.GroupName

+
+ @if (!string.IsNullOrWhiteSpace(session.System)) + { + @GetSystemDisplayName(session.System) + } + @if (session.IsOneShot) + { + Ваншот + } + @if (!string.IsNullOrWhiteSpace(session.Format)) + { + @TranslateFormat(session.Format) + } +
@@ -50,14 +70,33 @@ else if (session is not null) Статус @TranslateStatus(session.Status) + @if (session.DurationMinutes.HasValue) + { +
+ Длительность + @FormatDuration(session.DurationMinutes.Value) +
+ } + @if (!string.IsNullOrWhiteSpace(session.Description)) + { +
+

Описание

+

@session.Description

+
+ } +
@if (!string.IsNullOrWhiteSpace(session.GroupSlug)) { Расписание клуба } Ссылка на сессию + @if (session.AllowDirectRegistration) + { + Записаться + }
} @@ -65,7 +104,7 @@ else if (session is not null) @code { [Parameter] public Guid SessionId { get; set; } - private WebPublicSession? session; + private ShowcaseSessionDto? session; private bool loaded; private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay"; @@ -75,11 +114,11 @@ else if (session is not null) protected override async Task OnParametersSetAsync() { loaded = false; - session = await SessionStore.GetPublicSessionAsync(SessionId); + session = await SessionStore.GetShowcaseSessionAsync(SessionId); loaded = true; } - private static string FormatSeats(WebPublicSession session) + private static string FormatSeats(ShowcaseSessionDto session) { var seats = session.MaxPlayers.HasValue ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" @@ -90,6 +129,35 @@ else if (session is not null) : seats; } + private static string FormatDuration(int minutes) + { + if (minutes < 60) + return $"{minutes} мин"; + + var hours = minutes / 60; + var mins = minutes % 60; + return mins > 0 ? $"{hours} ч {mins} мин" : $"{hours} ч"; + } + + private static string GetSystemDisplayName(string? system) + { + if (string.IsNullOrWhiteSpace(system)) + return system ?? string.Empty; + + if (Enum.TryParse(system, out var gs)) + return gs.ToDisplayName(); + + return system; + } + + private static string TranslateFormat(string format) => format switch + { + "Online" => "Онлайн", + "Offline" => "Офлайн", + "Hybrid" => "Гибрид", + _ => format + }; + private static string GetStatusClass(string status) => status switch { SessionStatus.Confirmed => "status-success", diff --git a/src/GmRelay.Web/Services/SessionService.cs b/src/GmRelay.Web/Services/SessionService.cs index 815512f..3e5f849 100644 --- a/src/GmRelay.Web/Services/SessionService.cs +++ b/src/GmRelay.Web/Services/SessionService.cs @@ -127,7 +127,8 @@ internal sealed record ShowcaseSessionRow( int? MaxPlayers, int ActivePlayerCount, int WaitlistedPlayerCount, - bool AllowDirectRegistration); + bool AllowDirectRegistration, + string? Description); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -400,7 +401,8 @@ public sealed class SessionService( s.max_players AS MaxPlayers, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, - s.allow_direct_registration AS AllowDirectRegistration + s.allow_direct_registration AS AllowDirectRegistration, + s.description AS Description FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( @@ -463,7 +465,8 @@ public sealed class SessionService( return rows.Select(r => new ShowcaseSessionDto( r.Id, r.GroupId, r.GroupName, r.GroupSlug, r.Title, r.ScheduledAt, r.Status, r.System, r.IsOneShot, r.Format, r.DurationMinutes, r.CoverImageUrl, - r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration)).ToList(); + r.MaxPlayers, r.ActivePlayerCount, r.WaitlistedPlayerCount, r.AllowDirectRegistration, + r.Description)).ToList(); } public async Task GetShowcaseSessionAsync(Guid sessionId) @@ -486,7 +489,8 @@ public sealed class SessionService( s.max_players AS MaxPlayers, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount, - s.allow_direct_registration AS AllowDirectRegistration + s.allow_direct_registration AS AllowDirectRegistration, + s.description AS Description FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( @@ -531,7 +535,8 @@ public sealed class SessionService( return new ShowcaseSessionDto( row.Id, row.GroupId, row.GroupName, row.GroupSlug, row.Title, row.ScheduledAt, row.Status, row.System, row.IsOneShot, row.Format, row.DurationMinutes, row.CoverImageUrl, - row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration); + row.MaxPlayers, row.ActivePlayerCount, row.WaitlistedPlayerCount, row.AllowDirectRegistration, + row.Description); } public async Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) From 4145cacc5238b559136ae56ac15d3acb8e997b63 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:18:58 +0300 Subject: [PATCH 09/17] fix(web): add session-description styles for public session detail --- src/GmRelay.Web/wwwroot/app.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/GmRelay.Web/wwwroot/app.css b/src/GmRelay.Web/wwwroot/app.css index 15c64e1..2daf2d5 100644 --- a/src/GmRelay.Web/wwwroot/app.css +++ b/src/GmRelay.Web/wwwroot/app.css @@ -1795,6 +1795,24 @@ body.telegram-mini-app .session-card-mobile { color: var(--text-primary); } +.session-description { + margin: 1.25rem 0; + padding: 1rem; + background: var(--bg-surface); + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); +} + +.session-description h3 { + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.session-description p { + margin: 0; + color: var(--text-secondary); +} + .public-empty-state h2 { font-size: 1.125rem; margin-bottom: 0.5rem; From ab38238fe8aca04cb83287b92a637c1ccb3d9db3 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:20:44 +0300 Subject: [PATCH 10/17] feat(shared): extend CreateSessionCommand with showcase metadata Co-Authored-By: Claude Opus 4.7 --- .../Sessions/CreateSession/CreateSessionCommand.cs | 8 +++++++- .../Sessions/CreateSession/CreateSessionHandler.cs | 11 ++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs index 3f63c5e..d92f51f 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs @@ -1,3 +1,4 @@ +using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; namespace GmRelay.Shared.Features.Sessions.CreateSession; @@ -9,4 +10,9 @@ public sealed record CreateSessionCommand( string Link, IReadOnlyList ScheduledTimes, int? MaxPlayers, - string? ImageReference); + string? ImageReference, + GameSystem? System = null, + string? Description = null, + string? Format = null, + int? DurationMinutes = null, + bool IsOneShot = false); diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs index 4c96744..4f5d9b8 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -117,8 +117,8 @@ public sealed class CreateSessionHandler( { var sessionId = await connection.ExecuteScalarAsync( """ - INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers) + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot) + VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot) RETURNING id; """, new @@ -129,7 +129,12 @@ public sealed class CreateSessionHandler( Link = command.Link, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, - MaxPlayers = command.MaxPlayers + MaxPlayers = command.MaxPlayers, + System = command.System, + command.Description, + command.Format, + DurationMinutes = command.DurationMinutes, + IsOneShot = command.IsOneShot }, transaction); From 633a020212f7a280add65a012b8585ea11c04280 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:26:54 +0300 Subject: [PATCH 11/17] fix(shared): convert GameSystem to string in SQL, guard rollback after commit --- .../Sessions/CreateSession/CreateSessionHandler.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs index 4f5d9b8..5cb7c32 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -16,6 +16,7 @@ public sealed class CreateSessionHandler( await using var connection = await dataSource.OpenConnectionAsync(ct); await using var transaction = await connection.BeginTransactionAsync(ct); + var transactionCommitted = false; try { var platform = command.User.Platform.ToString(); @@ -130,7 +131,7 @@ public sealed class CreateSessionHandler( ScheduledAt = scheduledAt, Status = SessionStatus.Planned, MaxPlayers = command.MaxPlayers, - System = command.System, + System = command.System?.ToString(), command.Description, command.Format, DurationMinutes = command.DurationMinutes, @@ -142,6 +143,7 @@ public sealed class CreateSessionHandler( } await transaction.CommitAsync(ct); + transactionCommitted = true; var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty()); @@ -155,7 +157,10 @@ public sealed class CreateSessionHandler( } catch { - await transaction.RollbackAsync(ct); + if (!transactionCommitted) + { + await transaction.RollbackAsync(ct); + } throw; } } From 76c6818952dae6a313d0c03841c9b78201ef50eb Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:28:25 +0300 Subject: [PATCH 12/17] fix(shared): add missing Platform parameter in players upsert --- .../Features/Sessions/CreateSession/CreateSessionHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs index 5cb7c32..a8c551f 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -34,7 +34,7 @@ public sealed class CreateSessionHandler( SET display_name = EXCLUDED.display_name, external_username = EXCLUDED.external_username; """, - new { ExternalId = externalUserId, Name = displayName, Username = externalUsername }, + new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform }, transaction); var existingGroup = await connection.QuerySingleOrDefaultAsync( From b496a401fcd0b59c62f93ebd4cab7fb7b39f43bd Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:33:29 +0300 Subject: [PATCH 13/17] test: add GameSystem fuzzy matching and showcase query tests Co-Authored-By: Claude Opus 4.7 --- .../Domain/GameSystemTests.cs | 61 +++++++++++++++++++ .../Web/AuthorizedSessionServiceTests.cs | 10 +++ .../Web/ShowcaseQueryTests.cs | 55 +++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs create mode 100644 tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs diff --git a/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs b/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs new file mode 100644 index 0000000..3834ec0 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs @@ -0,0 +1,61 @@ +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Tests.Domain; + +public sealed class GameSystemTests +{ + [Theory] + [InlineData("Dnd5e", GameSystem.Dnd5e)] + [InlineData("D&D", GameSystem.Dnd5e)] + [InlineData("dnd5e", GameSystem.Dnd5e)] + [InlineData("pathfinder", GameSystem.Pathfinder2e)] + [InlineData("call of cthulhu", GameSystem.CallOfCthulhu7e)] + [InlineData("shadow", GameSystem.Shadowdark)] + [InlineData("unknown xyz", GameSystem.Other)] + public void TryParseFuzzy_ShouldMapInputToExpectedSystem(string input, GameSystem expected) + { + var result = GameSystemExtensions.TryParseFuzzy(input); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("днд")] + [InlineData("колова")] + public void TryParseFuzzy_ShouldReturnOtherForUnmatchedCyrillicInput(string input) + { + var result = GameSystemExtensions.TryParseFuzzy(input); + + Assert.Equal(GameSystem.Other, result); + } + + [Fact] + public void TryParseFuzzy_ShouldReturnNullForNullInput() + { + var result = GameSystemExtensions.TryParseFuzzy(null!); + + Assert.Null(result); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void TryParseFuzzy_ShouldReturnNullForEmptyOrWhitespaceInput(string input) + { + var result = GameSystemExtensions.TryParseFuzzy(input); + + Assert.Null(result); + } + + [Theory] + [InlineData(GameSystem.Dnd5e, "D&D 5e")] + [InlineData(GameSystem.Other, "Другое")] + [InlineData(GameSystem.Pathfinder2e, "Pathfinder 2e")] + [InlineData(GameSystem.Shadowdark, "Shadowdark")] + public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected) + { + var result = system.ToDisplayName(); + + Assert.Equal(expected, result); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs index b54dbb5..3675b8c 100644 --- a/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/AuthorizedSessionServiceTests.cs @@ -2,6 +2,7 @@ using GmRelay.Web.Services; using System.Security.Claims; using Microsoft.AspNetCore.Http; using GmRelay.Shared.Domain; +using GmRelay.Shared.Features.Showcase; namespace GmRelay.Bot.Tests.Web; @@ -1209,6 +1210,15 @@ public sealed class AuthorizedSessionServiceTests public Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl) => Task.CompletedTask; + public Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) => + Task.FromResult>([]); + + public Task GetShowcaseSessionAsync(Guid sessionId) => + Task.FromResult(null); + + public Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) => + Task.FromResult(false); + private bool IsManager(Guid groupId, long telegramId) => IsOwner(groupId, telegramId) || managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId); diff --git a/tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs b/tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs new file mode 100644 index 0000000..5914bb7 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs @@ -0,0 +1,55 @@ +using GmRelay.Bot.Features.Sessions.CreateSession; +using GmRelay.Shared.Domain; + +namespace GmRelay.Bot.Tests.Web; + +public sealed class ShowcaseQueryTests +{ + [Fact] + public void RegisterFromShowcase_WhenUnlimitedSeats_ShouldReturnActive() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5); + + Assert.Equal(ParticipantRegistrationStatus.Active, status); + } + + [Fact] + public void RegisterFromShowcase_WhenOneFreeSeat_ShouldReturnActive() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 4, activeParticipants: 3); + + Assert.Equal(ParticipantRegistrationStatus.Active, status); + } + + [Fact] + public void RegisterFromShowcase_WhenExactlyAtCapacity_ShouldReturnWaitlisted() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 4, activeParticipants: 4); + + Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); + } + + [Fact] + public void RegisterFromShowcase_WhenOverCapacity_ShouldReturnWaitlisted() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5); + + Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); + } + + [Fact] + public void RegisterFromShowcase_WhenZeroActiveAndZeroMax_ShouldReturnWaitlisted() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 0, activeParticipants: 0); + + Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); + } + + [Fact] + public void RegisterFromShowcase_WhenZeroActiveAndPositiveMax_ShouldReturnActive() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0); + + Assert.Equal(ParticipantRegistrationStatus.Active, status); + } +} From 5b6971fda5f7393bc3b4077e0eac7132f3f92bf3 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:40:11 +0300 Subject: [PATCH 14/17] test: consolidate capacity tests, add GameSystem edge cases, remove ShowcaseQueryTests --- .../Domain/GameSystemTests.cs | 4 ++ .../SessionCapacityRulesTests.cs | 24 ++++++++ .../Web/ShowcaseQueryTests.cs | 55 ------------------- 3 files changed, 28 insertions(+), 55 deletions(-) delete mode 100644 tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs diff --git a/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs b/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs index 3834ec0..868d734 100644 --- a/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs +++ b/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs @@ -8,9 +8,12 @@ public sealed class GameSystemTests [InlineData("Dnd5e", GameSystem.Dnd5e)] [InlineData("D&D", GameSystem.Dnd5e)] [InlineData("dnd5e", GameSystem.Dnd5e)] + [InlineData(" dnd5e ", GameSystem.Dnd5e)] + [InlineData("D&D 5e", GameSystem.Dnd5e)] [InlineData("pathfinder", GameSystem.Pathfinder2e)] [InlineData("call of cthulhu", GameSystem.CallOfCthulhu7e)] [InlineData("shadow", GameSystem.Shadowdark)] + [InlineData("dark", GameSystem.Shadowdark)] [InlineData("unknown xyz", GameSystem.Other)] public void TryParseFuzzy_ShouldMapInputToExpectedSystem(string input, GameSystem expected) { @@ -52,6 +55,7 @@ public sealed class GameSystemTests [InlineData(GameSystem.Other, "Другое")] [InlineData(GameSystem.Pathfinder2e, "Pathfinder 2e")] [InlineData(GameSystem.Shadowdark, "Shadowdark")] + [InlineData((GameSystem)999, "Другое")] public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected) { var result = system.ToDisplayName(); diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs index f3fd8f9..5dfbf5e 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/SessionCapacityRulesTests.cs @@ -21,6 +21,30 @@ public sealed class SessionCapacityRulesTests Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); } + [Fact] + public void DecideJoinStatus_ShouldReturnActive_WhenUnlimitedSeats() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5); + + Assert.Equal(ParticipantRegistrationStatus.Active, status); + } + + [Fact] + public void DecideJoinStatus_ShouldReturnWaitlisted_WhenOverCapacity() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5); + + Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); + } + + [Fact] + public void DecideJoinStatus_ShouldReturnActive_WhenZeroActiveAndPositiveMax() + { + var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0); + + Assert.Equal(ParticipantRegistrationStatus.Active, status); + } + [Fact] public void CanPromoteWaitlistedPlayer_ShouldRequireWaitlistAndFreeSeat() { diff --git a/tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs b/tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs deleted file mode 100644 index 5914bb7..0000000 --- a/tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs +++ /dev/null @@ -1,55 +0,0 @@ -using GmRelay.Bot.Features.Sessions.CreateSession; -using GmRelay.Shared.Domain; - -namespace GmRelay.Bot.Tests.Web; - -public sealed class ShowcaseQueryTests -{ - [Fact] - public void RegisterFromShowcase_WhenUnlimitedSeats_ShouldReturnActive() - { - var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: null, activeParticipants: 5); - - Assert.Equal(ParticipantRegistrationStatus.Active, status); - } - - [Fact] - public void RegisterFromShowcase_WhenOneFreeSeat_ShouldReturnActive() - { - var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 4, activeParticipants: 3); - - Assert.Equal(ParticipantRegistrationStatus.Active, status); - } - - [Fact] - public void RegisterFromShowcase_WhenExactlyAtCapacity_ShouldReturnWaitlisted() - { - var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 4, activeParticipants: 4); - - Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); - } - - [Fact] - public void RegisterFromShowcase_WhenOverCapacity_ShouldReturnWaitlisted() - { - var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 3, activeParticipants: 5); - - Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); - } - - [Fact] - public void RegisterFromShowcase_WhenZeroActiveAndZeroMax_ShouldReturnWaitlisted() - { - var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 0, activeParticipants: 0); - - Assert.Equal(ParticipantRegistrationStatus.Waitlisted, status); - } - - [Fact] - public void RegisterFromShowcase_WhenZeroActiveAndPositiveMax_ShouldReturnActive() - { - var status = SessionCapacityRules.DecideJoinStatus(maxPlayers: 1, activeParticipants: 0); - - Assert.Equal(ParticipantRegistrationStatus.Active, status); - } -} From 9d9aca53dfb2cbf4cba043a408f1a4f5d411aa0a Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 16:43:11 +0300 Subject: [PATCH 15/17] chore: bump version to 3.4.0 --- .gitea/workflows/deploy.yml | 2 +- Directory.Build.props | 2 +- compose.yaml | 6 +++--- src/GmRelay.Web/Components/Layout/NavMenu.razor | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b2762c4..a4fffc1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.3.0 + VERSION: 3.4.0 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index ae7048f..0e66213 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.3.0 + 3.4.0 net10.0 preview enable diff --git a/compose.yaml b/compose.yaml index af34e79..e6a0f10 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.3.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.4.0 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.3.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.4.0 restart: always depends_on: db: @@ -84,7 +84,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.3.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.4.0 restart: always depends_on: db: diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 3548a4f..830698b 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -73,7 +73,7 @@ - + From a63e3bef1e7369c3539a3a5960be99065f9c51d5 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 17:00:33 +0300 Subject: [PATCH 16/17] =?UTF-8?q?fix:=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B4=D0=BB=D1=8F=20issue?= =?UTF-8?q?=20#39?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PublicSession.razor: добавлена обработка ?register=1, AuthStateProvider, TryGetPlatformIdentity, кнопки записи для авторизованных/неавторизованных пользователей, отображение результата регистрации - CreateSessionHandler: добавлен cover_image_url в INSERT SQL - DiscordProjectStructureTests: версия 3.4.0 во всех проверках - README.md: актуальная версия v3.4.0 Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- .../CreateSession/CreateSessionHandler.cs | 7 ++- .../Components/Pages/PublicSession.razor | 57 ++++++++++++++++++- .../Discord/DiscordProjectStructureTests.cs | 14 ++--- 4 files changed, 68 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8722327..25761c5 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire. -**Текущая версия:** `v3.3.0`. +**Текущая версия:** `v3.4.0`. --- diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs index a8c551f..b4bedb5 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -118,8 +118,8 @@ public sealed class CreateSessionHandler( { var sessionId = await connection.ExecuteScalarAsync( """ - INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot) - VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot) + INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url) + VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl) RETURNING id; """, new @@ -135,7 +135,8 @@ public sealed class CreateSessionHandler( command.Description, command.Format, DurationMinutes = command.DurationMinutes, - IsOneShot = command.IsOneShot + IsOneShot = command.IsOneShot, + CoverImageUrl = command.ImageReference }, transaction); diff --git a/src/GmRelay.Web/Components/Pages/PublicSession.razor b/src/GmRelay.Web/Components/Pages/PublicSession.razor index 0fc2fae..b96863d 100644 --- a/src/GmRelay.Web/Components/Pages/PublicSession.razor +++ b/src/GmRelay.Web/Components/Pages/PublicSession.razor @@ -2,7 +2,9 @@ @layout PublicLayout @inject ISessionStore SessionStore @inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider @using GmRelay.Shared.Features.Showcase +@using GmRelay.Web.Services @PageTitleText @@ -87,6 +89,13 @@ else if (session is not null) } + @if (registrationResult is not null) + { +
+

@registrationResult

+
+ } +
@if (!string.IsNullOrWhiteSpace(session.GroupSlug)) { @@ -95,7 +104,14 @@ else if (session is not null) Ссылка на сессию @if (session.AllowDirectRegistration) { - Записаться + @if (isAuthenticated) + { + + } + else + { + Войти, чтобы записаться + } }
@@ -106,6 +122,8 @@ else if (session is not null) private ShowcaseSessionDto? session; private bool loaded; + private bool isAuthenticated; + private string? registrationResult; private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay"; @@ -114,10 +132,47 @@ else if (session is not null) protected override async Task OnParametersSetAsync() { loaded = false; + registrationResult = null; session = await SessionStore.GetShowcaseSessionAsync(SessionId); + + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false; + + if (session is not null && Navigation.Uri.Contains("register=1") && session.AllowDirectRegistration) + { + if (isAuthenticated && authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) + { + var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок"); + registrationResult = success + ? "Вы успешно записались на игру!" + : "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы."; + } + else if (!isAuthenticated) + { + Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}")}"); + return; + } + } + loaded = true; } + private async Task RegisterAsync() + { + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + if (authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) + { + var success = await SessionStore.RegisterFromShowcaseAsync(SessionId, platform, externalUserId, authState.User.Identity?.Name ?? "Игрок"); + registrationResult = success + ? "Вы успешно записались на игру!" + : "Не удалось записаться. Возможно, места закончились или вы уже зарегистрированы."; + } + } + + private string GetLoginUrl() => $"/login?returnUrl={Uri.EscapeDataString($"/s/{SessionId}?register=1")}"; + + private string GetRegistrationResultClass() => registrationResult?.StartsWith("Вы успешно") == true ? "status-success-bg" : "status-warning-bg"; + private static string FormatSeats(ShowcaseSessionDto session) { var seats = session.MaxPlayers.HasValue diff --git a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs index 2a01a82..0ee8503 100644 --- a/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs +++ b/tests/GmRelay.Bot.Tests/Discord/DiscordProjectStructureTests.cs @@ -62,7 +62,7 @@ public sealed class DiscordProjectStructureTests var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml")); var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")); - Assert.Contains("gmrelay-discord-bot:3.3.0", compose); + Assert.Contains("gmrelay-discord-bot:3.4.0", compose); Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose); Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy); Assert.Contains("DISCORD_BOT_TOKEN", deploy); @@ -76,13 +76,13 @@ public sealed class DiscordProjectStructureTests { var repoRoot = GetRepoRoot(); - Assert.Contains("3.3.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); - Assert.Contains("VERSION: 3.3.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); - Assert.Contains("gmrelay-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-web:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); - Assert.Contains("gmrelay-discord-bot:3.3.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("3.4.0", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props"))); + Assert.Contains("VERSION: 3.4.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"))); + Assert.Contains("gmrelay-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-web:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); + Assert.Contains("gmrelay-discord-bot:3.4.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml"))); Assert.Contains( - "v3.3.0", + "v3.4.0", File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor"))); } From accb3b24052209ecf4583885c8d6a35311a315c4 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 17:28:51 +0300 Subject: [PATCH 17/17] =?UTF-8?q?fix(web):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20query=20string=20=D0=B4=D0=BB=D1=8F=20=3Fr?= =?UTF-8?q?egister=3D1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменен Navigation.Uri.Contains() на QueryHelpers.ParseQuery для корректного определения параметра register без ложных срабатываний на подстроки (например, register=10). Co-Authored-By: Claude Opus 4.7 --- src/GmRelay.Web/Components/Pages/PublicSession.razor | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/GmRelay.Web/Components/Pages/PublicSession.razor b/src/GmRelay.Web/Components/Pages/PublicSession.razor index b96863d..aa7c11d 100644 --- a/src/GmRelay.Web/Components/Pages/PublicSession.razor +++ b/src/GmRelay.Web/Components/Pages/PublicSession.razor @@ -138,7 +138,11 @@ else if (session is not null) var authState = await AuthStateProvider.GetAuthenticationStateAsync(); isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false; - if (session is not null && Navigation.Uri.Contains("register=1") && session.AllowDirectRegistration) + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + var shouldRegister = query.TryGetValue("register", out var val) && val == "1"; + + if (session is not null && shouldRegister && session.AllowDirectRegistration) { if (isAuthenticated && authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId)) {