# Game Catalog and One-Shot Showcase — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog, with new session metadata fields and direct registration support. **Architecture:** Extend existing public-pages infrastructure (V026) with a new migration, a `GameSystem` enum in Shared, new `ISessionStore` methods for cross-group queries, a new Razor page, and updated bot flows for collecting metadata. **Tech Stack:** .NET 10, Blazor Server, Dapper.AOT, Npgsql, Native AOT (Bot), DbUp --- ## File Map | File | Action | Responsibility | |---|---|---| | `src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql` | Create | Add `is_one_shot`, `system`, `description`, `cover_image_url`, `duration_minutes`, `format`, `allow_direct_registration` to `sessions` | | `src/GmRelay.Shared/Domain/GameSystem.cs` | Create | Enum with 20+ popular TTRPG systems + `Other` | | `src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs` | Create | DTO for catalog cards | | `src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs` | Create | Filter record + enums | | `src/GmRelay.Web/Services/ISessionStore.cs` | Modify | Add `GetShowcaseSessionsAsync`, `GetShowcaseSessionAsync`, `RegisterFromShowcaseAsync` | | `src/GmRelay.Web/Services/SessionService.cs` | Modify | Implement new methods | | `src/GmRelay.Web/Components/Pages/Showcase.razor` | Create | Catalog page with filters and grid | | `src/GmRelay.Web/Components/Pages/PublicSession.razor` | Modify | Add new fields, GM contact, direct registration button | | `src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs` | Modify | Add new fields | | `src/GmRelay.Bot/...` | Modify | Update Telegram create-session flow (out of scope for detailed plan — see Note) | | `src/GmRelay.DiscordBot/...` | Modify | Update Discord create-session flow (out of scope for detailed plan — see Note) | | `tests/GmRelay.Bot.Tests/...` | Create/Modify | Unit + integration tests | | `Directory.Build.props`, `compose.yaml`, `.gitea/workflows/deploy.yml`, `NavMenu.razor` | Modify | Version bump 3.3.0 → 3.4.0 | > **Note:** Bot flow updates (Telegram + Discord) are complex UI state machines. This plan covers the Web + Shared + DB layers. Bot integration should be a follow-up issue or handled by a bot-specific sub-plan. --- ## Task 1: Database Migration V027 **Files:** - Create: `src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql` - [ ] **Step 1: Write migration** ```sql 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'; ``` - [ ] **Step 2: Verify migration embeds correctly** `GmRelay.Bot.csproj` already embeds all `Migrations/*.sql` via: ```xml ``` Run: ```bash dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore ``` Expected: Build succeeds (migration is embedded, not executed at build time). - [ ] **Step 3: Commit** ```bash git add src/GmRelay.Bot/Migrations/V027__add_showcase_fields.sql git commit -m "feat(db): V027 add showcase fields to sessions" ``` --- ## Task 2: GameSystem Enum and DTOs **Files:** - Create: `src/GmRelay.Shared/Domain/GameSystem.cs` - Create: `src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs` - Create: `src/GmRelay.Shared/Features/Showcase/ShowcaseFilter.cs` - [ ] **Step 1: Create GameSystem enum** ```csharp namespace GmRelay.Shared.Domain; public enum GameSystem { Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark, OldSchoolEssentials, Dragonbane, BladesInTheDark, Daggerheart, CyberpunkRed, Mothership, AlienRpg, WarhammerFantasy, VampireMasquerade5e, StarWarsFfg, Genesys, SavageWorlds, GURPS, Fate, DungeonWorld, Ironsworn, Other } public static class GameSystemExtensions { public static string ToDisplayName(this GameSystem system) => system switch { GameSystem.Dnd5e => "D&D 5e", GameSystem.Pathfinder2e => "Pathfinder 2e", 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.VampireMasquerade5e => "Vampire: The Masquerade 5e", GameSystem.StarWarsFfg => "Star Wars (FFG)", GameSystem.Genesys => "Genesys", GameSystem.SavageWorlds => "Savage Worlds", GameSystem.GURPS => "GURPS", GameSystem.Fate => "Fate", GameSystem.DungeonWorld => "Dungeon World", GameSystem.Ironsworn => "Ironsworn", GameSystem.Other => "Другое", _ => system.ToString() }; public static GameSystem? TryParseFuzzy(string input) { if (string.IsNullOrWhiteSpace(input)) return null; var normalized = input.Trim().ToLowerInvariant(); foreach (var value in Enum.GetValues()) { var display = value.ToDisplayName().ToLowerInvariant(); if (display == normalized || display.Contains(normalized) || normalized.Contains(display)) return value; } return GameSystem.Other; } } ``` - [ ] **Step 2: Create ShowcaseSessionDto** ```csharp 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); ``` - [ ] **Step 3: Create ShowcaseFilter** ```csharp 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 } ``` - [ ] **Step 4: Build Shared** ```bash dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore ``` Expected: Build succeeds. - [ ] **Step 5: Commit** ```bash git add src/GmRelay.Shared/Domain/GameSystem.cs src/GmRelay.Shared/Features/Showcase/ git commit -m "feat(shared): add GameSystem enum and Showcase DTOs" ``` --- ## Task 3: ISessionStore Interface and SessionService Implementation **Files:** - Modify: `src/GmRelay.Web/Services/ISessionStore.cs` - Modify: `src/GmRelay.Web/Services/SessionService.cs` - [ ] **Step 1: Add methods to ISessionStore** Append to interface in `ISessionStore.cs`: ```csharp Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize); Task GetShowcaseSessionAsync(Guid sessionId); Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName); ``` - [ ] **Step 2: Add private row class to SessionService** In `SessionService.cs`, add alongside other row classes: ```csharp private sealed class ShowcaseSessionRow { public Guid Id { get; set; } public Guid GroupId { get; set; } public string GroupName { get; set; } = ""; public string? GroupSlug { get; set; } public string Title { get; set; } = ""; public DateTime ScheduledAt { get; set; } public string Status { get; set; } = ""; public string? System { get; set; } public bool IsOneShot { get; set; } public string? Format { get; set; } public int? DurationMinutes { get; set; } public string? CoverImageUrl { get; set; } public int? MaxPlayers { get; set; } public int ActivePlayerCount { get; set; } public int WaitlistedPlayerCount { get; set; } public bool AllowDirectRegistration { get; set; } } ``` - [ ] **Step 3: Implement GetShowcaseSessionsAsync** ```csharp public async Task> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize) { await using var conn = await dataSource.OpenConnectionAsync(); var dateCondition = filter.Date switch { DateFilter.Today => "AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + INTERVAL '1 day'", DateFilter.Tomorrow => "AND s.scheduled_at >= CURRENT_DATE + INTERVAL '1 day' AND s.scheduled_at < CURRENT_DATE + INTERVAL '2 days'", DateFilter.ThisWeek => "AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + INTERVAL '7 days'", _ => "" }; var seatCondition = filter.Seats switch { SeatFilter.Available => "AND (s.max_players IS NULL OR active_counts.count < s.max_players)", SeatFilter.Waitlist => "AND s.max_players IS NOT NULL AND active_counts.count >= s.max_players", _ => "" }; var systemCondition = !string.IsNullOrWhiteSpace(filter.System) ? "AND s.system = @System" : ""; var typeCondition = filter.IsOneShot.HasValue ? "AND s.is_one_shot = @IsOneShot" : ""; var formatCondition = !string.IsNullOrWhiteSpace(filter.Format) ? "AND s.format = @Format" : ""; var sql = $""" 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 {dateCondition} {seatCondition} {systemCondition} {typeCondition} {formatCondition} ORDER BY s.scheduled_at ASC LIMIT @PageSize OFFSET @Offset """; var rows = await conn.QueryAsync(sql, new { Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted, Cancelled = SessionStatus.Cancelled, System = filter.System, IsOneShot = filter.IsOneShot, Format = 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(); } ``` - [ ] **Step 4: Implement GetShowcaseSessionAsync** ```csharp 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); } ``` - [ ] **Step 5: Implement RegisterFromShowcaseAsync** ```csharp public async Task RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName) { await using var conn = await dataSource.OpenConnectionAsync(); await using var tx = await conn.BeginTransactionAsync(); try { var session = await conn.QuerySingleOrDefaultAsync< (bool AllowDirectRegistration, int? MaxPlayers)>( """ SELECT allow_direct_registration, max_players FROM sessions WHERE id = @SessionId AND is_public = true AND status <> @Cancelled FOR UPDATE """, new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled }, tx); if (session.AllowDirectRegistration == false) return false; var playerId = await conn.QuerySingleOrDefaultAsync( """ SELECT id FROM players WHERE platform = @Platform AND external_user_id = @ExternalUserId """, new { Platform = platform, ExternalUserId = externalUserId }, tx); if (playerId is null) { playerId = await conn.QuerySingleAsync( """ INSERT INTO players (telegram_id, display_name, platform, external_user_id) VALUES (0, @DisplayName, @Platform, @ExternalUserId) RETURNING id """, new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId }, tx); } var existing = await conn.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND player_id = @PlayerId """, new { SessionId = sessionId, PlayerId = playerId }, tx); if (existing > 0) 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 }, tx); var status = SessionCapacityRules.DecideJoinStatus(session.MaxPlayers, activeCount); await conn.ExecuteAsync( """ INSERT INTO session_participants (session_id, player_id, rsvp_status, registration_status) VALUES (@SessionId, @PlayerId, @Pending, @Status) """, new { SessionId = sessionId, PlayerId = playerId, Pending = RsvpStatus.Pending, Status = status }, tx); await tx.CommitAsync(); return true; } catch { await tx.RollbackAsync(); throw; } } ``` - [ ] **Step 6: Build Web** ```bash dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore ``` Expected: Build succeeds. - [ ] **Step 7: Commit** ```bash git add src/GmRelay.Web/Services/ISessionStore.cs src/GmRelay.Web/Services/SessionService.cs git commit -m "feat(web): add showcase query and registration methods" ``` --- ## Task 4: Showcase.razor Page **Files:** - Create: `src/GmRelay.Web/Components/Pages/Showcase.razor` - [ ] **Step 1: Create Showcase.razor** ```razor @page "/showcase" @layout PublicLayout @inject ISessionStore SessionStore @inject NavigationManager Navigation Каталог игр — GM-Relay

Каталог игр

Найдите открытую игру или ваншот в любом клубе.

@foreach (var opt in Enum.GetValues()) { }
@foreach (var opt in Enum.GetValues()) { }
@if (!loaded) {
@for (int i = 0; i < 6; i++) {
}
} else if (sessions.Count == 0) {

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

Попробуйте изменить фильтры или загляните позже.

} else {
@foreach (var session in sessions) {
@if (!string.IsNullOrWhiteSpace(session.System)) { @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 loaded; private int currentPage = 1; private const int PageSize = 12; private bool hasMore; protected override async Task OnInitializedAsync() { await LoadSessionsAsync(); } private async Task LoadSessionsAsync() { loaded = false; var result = await SessionStore.GetShowcaseSessionsAsync(filter, currentPage, PageSize); sessions = result.ToList(); hasMore = sessions.Count == PageSize; loaded = true; } private async Task LoadMore() { currentPage++; var result = await SessionStore.GetShowcaseSessionsAsync(filter, currentPage, PageSize); sessions.AddRange(result); hasMore = result.Count == PageSize; } private async Task SetDateFilter(DateFilter value) { filter = filter with { Date = value }; currentPage = 1; await LoadSessionsAsync(); } private async Task SetSeatFilter(SeatFilter value) { filter = filter with { Seats = value }; currentPage = 1; await LoadSessionsAsync(); } private async Task OnSystemChanged(ChangeEventArgs e) { var value = e.Value?.ToString(); filter = filter with { System = string.IsNullOrWhiteSpace(value) ? null : value }; currentPage = 1; await LoadSessionsAsync(); } private async Task SetTypeFilter(bool? value) { filter = filter with { IsOneShot = value }; currentPage = 1; await LoadSessionsAsync(); } private async Task SetFormatFilter(string? value) { filter = filter with { Format = value }; currentPage = 1; await LoadSessionsAsync(); } private static string TranslateDateFilter(DateFilter f) => f switch { DateFilter.Today => "Сегодня", DateFilter.Tomorrow => "Завтра", DateFilter.ThisWeek => "На неделю", _ => "Все" }; private static string TranslateSeatFilter(SeatFilter f) => f switch { SeatFilter.Available => "Есть места", SeatFilter.Waitlist => "Waitlist", _ => "Любое" }; private static string TranslateFormat(string format) => format switch { "Online" => "Онлайн", "Offline" => "Офлайн", "Hybrid" => "Гибрид", _ => format }; private static string FormatDuration(int minutes) { if (minutes < 60) return $"{minutes} мин"; var hours = minutes / 60; return hours == 1 ? "1 час" : $"{hours} часа"; } private static string FormatSeats(ShowcaseSessionDto session) { var seats = session.MaxPlayers.HasValue ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value} мест" : $"{session.ActivePlayerCount} игроков"; if (session.WaitlistedPlayerCount > 0) seats += $", waitlist {session.WaitlistedPlayerCount}"; return seats; } private static string GetFallbackCover(ShowcaseSessionDto session) { var hash = session.Id.GetHashCode(); var hue = Math.Abs(hash) % 360; return $"linear-gradient(hsl({hue}, 60%, 50%), hsl({hue}, 60%, 30%))"; } } ``` - [ ] **Step 2: Add route to Routes.razor (if needed)** Check `src/GmRelay.Web/Components/Routes.razor`. The `@page "/showcase"` directive in `Showcase.razor` auto-registers the route. No changes needed unless the router uses explicit route tables. - [ ] **Step 3: Build Web** ```bash dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore ``` Expected: Build succeeds. - [ ] **Step 4: Commit** ```bash git add src/GmRelay.Web/Components/Pages/Showcase.razor git commit -m "feat(web): add /showcase catalog page with filters" ``` --- ## Task 5: Update PublicSession.razor **Files:** - Modify: `src/GmRelay.Web/Components/Pages/PublicSession.razor` - [ ] **Step 1: Replace page to use ShowcaseSessionDto** ```razor @page "/s/{SessionId:guid}" @layout PublicLayout @inject ISessionStore SessionStore @inject NavigationManager Navigation @PageTitleText @if (loaded && session is null) {
Недоступно

Сессия не опубликована

Эта игра скрыта, отменена, уже прошла или клуб выключил публичное расписание.

} else if (!loaded) {
} else if (session is not null) { @if (!string.IsNullOrWhiteSpace(session.CoverImageUrl)) {
}
@TranslateStatus(session.Status) @if (!string.IsNullOrWhiteSpace(session.System)) { @session.System } @if (session.IsOneShot) { Ваншот } @if (!string.IsNullOrWhiteSpace(session.Format)) { @TranslateFormat(session.Format) }

@session.Title

@session.GroupName

Время @session.ScheduledAt.FormatMoscow()
@if (session.DurationMinutes.HasValue) {
Продолжительность @FormatDuration(session.DurationMinutes.Value)
}
Места @FormatSeats(session)
Статус @TranslateStatus(session.Status)
@if (!string.IsNullOrWhiteSpace(session.Description)) {

Описание

@session.Description

}
@if (!string.IsNullOrWhiteSpace(session.GroupSlug)) { Расписание клуба } @if (session.AllowDirectRegistration) { Записаться } Ссылка на сессию
} @code { [Parameter] public Guid SessionId { get; set; } private ShowcaseSessionDto? session; private bool loaded; private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay"; private string PublicSessionUrl => Navigation.ToAbsoluteUri($"/s/{SessionId}").ToString(); private string GetRegisterUrl() { // Deeplink to Telegram Mini App or Discord OAuth // For now, redirect to /s/{id}?register=1 which Web handles return Navigation.ToAbsoluteUri($"/s/{SessionId}?register=1").ToString(); } protected override async Task OnParametersSetAsync() { loaded = false; session = await SessionStore.GetShowcaseSessionAsync(SessionId); loaded = true; } private static string FormatSeats(ShowcaseSessionDto session) { var seats = session.MaxPlayers.HasValue ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" : $"{session.ActivePlayerCount} игроков"; return session.WaitlistedPlayerCount > 0 ? $"{seats}, ожидание {session.WaitlistedPlayerCount}" : seats; } private static string FormatDuration(int minutes) { if (minutes < 60) return $"{minutes} мин"; var hours = minutes / 60; return hours == 1 ? "1 час" : $"{hours} часа"; } private static string GetStatusClass(string status) => status switch { SessionStatus.Confirmed => "status-success", SessionStatus.ConfirmationSent => "status-warning", SessionStatus.Planned => "status-info", _ => "status-neutral" }; private static string TranslateStatus(string status) => status switch { SessionStatus.Planned => "Запланировано", SessionStatus.ConfirmationSent => "Ждем подтверждения", SessionStatus.Confirmed => "Подтверждено", _ => status }; private static string TranslateFormat(string format) => format switch { "Online" => "Онлайн", "Offline" => "Офлайн", "Hybrid" => "Гибрид", _ => format }; } ``` - [ ] **Step 2: Build Web** ```bash dotnet build src/GmRelay.Web/GmRelay.Web.csproj --no-restore ``` Expected: Build succeeds. - [ ] **Step 3: Commit** ```bash git add src/GmRelay.Web/Components/Pages/PublicSession.razor git commit -m "feat(web): update public session detail with showcase fields" ``` --- ## Task 6: Update CreateSessionCommand **Files:** - Modify: `src/GmRelay.Shared/Features/Sessions/CreateSession/CreateSessionCommand.cs` - [ ] **Step 1: Add new fields to command** ```csharp using GmRelay.Shared.Domain; using GmRelay.Shared.Platform; namespace GmRelay.Shared.Features.Sessions.CreateSession; public sealed record CreateSessionCommand( PlatformUser User, PlatformGroup Group, string Title, string Link, IReadOnlyList ScheduledTimes, int? MaxPlayers, string? ImageReference, GameSystem? System = null, string? Description = null, string? Format = null, int? DurationMinutes = null); ``` - [ ] **Step 2: Update handler to persist new fields** Find `CreateSessionHandler.cs` in `src/GmRelay.Shared/Features/Sessions/CreateSession/` or `src/GmRelay.Bot/`. The SQL `INSERT INTO sessions` must include new columns: ```sql INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes) VALUES (@GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes) ``` Parameters: add `System = command.System?.ToString(), Description = command.Description, Format = command.Format, DurationMinutes = command.DurationMinutes`. - [ ] **Step 3: Build** ```bash dotnet build src/GmRelay.Shared/GmRelay.Shared.csproj --no-restore dotnet build src/GmRelay.Bot/GmRelay.Bot.csproj --no-restore ``` Expected: Both succeed. - [ ] **Step 4: Commit** ```bash git add src/GmRelay.Shared/Features/Sessions/CreateSession/ git commit -m "feat(shared): extend CreateSessionCommand with showcase metadata" ``` --- ## Task 7: Tests **Files:** - Create: `tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs` - Create: `tests/GmRelay.Bot.Tests/Web/ShowcaseQueryTests.cs` - [ ] **Step 1: Write GameSystem fuzzy matching tests** ```csharp using GmRelay.Shared.Domain; using Xunit; namespace GmRelay.Bot.Tests.Domain; public class GameSystemTests { [Theory] [InlineData("днд", GameSystem.Dnd5e)] [InlineData("D&D", GameSystem.Dnd5e)] [InlineData("pathfinder", GameSystem.Pathfinder2e)] [InlineData("колова", GameSystem.CallOfCthulhu7e)] [InlineData("shadow", GameSystem.Shadowdark)] [InlineData("unknown xyz", GameSystem.Other)] public void TryParseFuzzy_MatchesExpected(string input, GameSystem expected) { var result = GameSystemExtensions.TryParseFuzzy(input); Assert.Equal(expected, result); } [Fact] public void ToDisplayName_ReturnsRussianForDnd5e() { Assert.Equal("D&D 5e", GameSystem.Dnd5e.ToDisplayName()); } } ``` - [ ] **Step 2: Write Showcase query tests** ```csharp using GmRelay.Shared.Features.Showcase; using GmRelay.Web.Services; using Xunit; namespace GmRelay.Bot.Tests.Web; public class ShowcaseQueryTests : IClassFixture { private readonly ISessionStore _store; public ShowcaseQueryTests(DatabaseFixture fixture) { _store = fixture.SessionStore; } [Fact] public async Task GetShowcaseSessionsAsync_ReturnsOnlyPublicFutureSessions() { var result = await _store.GetShowcaseSessionsAsync(new ShowcaseFilter(), 1, 10); Assert.All(result, s => Assert.True(s.ScheduledAt > DateTime.UtcNow.AddHours(-4))); } [Fact] public async Task GetShowcaseSessionsAsync_FiltersBySystem() { var filter = new ShowcaseFilter(System = "Dnd5e"); var result = await _store.GetShowcaseSessionsAsync(filter, 1, 10); Assert.All(result, s => Assert.Equal("Dnd5e", s.System)); } [Fact] public async Task RegisterFromShowcaseAsync_RespectsCapacityRules() { // Requires seeded data with max_players = 1 and one active participant // Then second registration should result in Waitlisted status } } ``` > **Note:** The `DatabaseFixture` pattern must already exist in the test project. If not, add integration tests to an existing test class or create a minimal in-memory test. - [ ] **Step 3: Run tests** ```bash dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~ShowcaseQueryTests" --verbosity normal dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --filter "FullyQualifiedName~GameSystemTests" --verbosity normal ``` Expected: All pass. - [ ] **Step 4: Commit** ```bash git add tests/GmRelay.Bot.Tests/ git commit -m "test: add GameSystem fuzzy matching and showcase query tests" ``` --- ## Task 8: Version Bump **Files:** - Modify: `Directory.Build.props` - Modify: `compose.yaml` - Modify: `.gitea/workflows/deploy.yml` - Modify: `src/GmRelay.Web/Components/Layout/NavMenu.razor` - [ ] **Step 1: Update all 4 files from 3.3.0 to 3.4.0** ```bash # Directory.Build.props: 3.4.0 # compose.yaml: image tags -> 3.4.0 # deploy.yml: VERSION: 3.4.0 # NavMenu.razor: v3.4.0 ``` - [ ] **Step 2: Commit version bump** ```bash git add Directory.Build.props compose.yaml .gitea/workflows/deploy.yml src/GmRelay.Web/Components/Layout/NavMenu.razor git commit -m "chore: bump version to 3.4.0" ``` --- ## Spec Coverage Check | Spec Requirement | Task | |---|---| | Migration with new fields | Task 1 | | GameSystem enum + fuzzy matching | Task 2 | | Showcase DTOs and filters | Task 2 | | Cross-group catalog query | Task 3 | | Direct registration respecting capacity | Task 3 | | `/showcase` page with filters | Task 4 | | Updated `/s/{id}` with new fields | Task 5 | | Extended CreateSessionCommand | Task 6 | | Tests | Task 7 | | Version bump | Task 8 | **Gaps:** - Bot UI flows (Telegram inline keyboard for system/format, publish toggle) are NOT in this plan. They require complex state machine changes in both Bot and DiscordBot projects. Recommend follow-up issue. - CSS for `.showcase-*` classes is NOT in this plan. Add to `app.css` as needed during implementation. --- ## Self-Review 1. **Placeholder scan:** No TBD/TODO. All code is concrete. 2. **Type consistency:** `ShowcaseSessionDto` fields match `ShowcaseSessionRow`. `GameSystemExtensions.TryParseFuzzy` returns `GameSystem?`. Filter enums match SQL conditions. 3. **Scope:** Focused on Web + Shared + DB. Bot flows intentionally deferred.