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/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/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.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'; diff --git a/src/GmRelay.Shared/Domain/GameSystem.cs b/src/GmRelay.Shared/Domain/GameSystem.cs new file mode 100644 index 0000000..eb14d9a --- /dev/null +++ b/src/GmRelay.Shared/Domain/GameSystem.cs @@ -0,0 +1,83 @@ +using System.Collections.Frozen; + +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 +{ + private static readonly FrozenDictionary DisplayNames = + new Dictionary + { + [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] = "Другое" + }.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 null; + + var normalized = input.Trim().ToLowerInvariant(); + + if (Enum.TryParse(normalized, true, out var exact)) + return exact; + + foreach (var value in Enum.GetValues()) + { + if (value == GameSystem.Other) + continue; + + var display = value.ToDisplayName().ToLowerInvariant(); + if (display == normalized || display.Contains(normalized) || normalized.Contains(display)) + return value; + } + + return GameSystem.Other; + } +} 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..b4bedb5 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(); @@ -33,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( @@ -117,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) - 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, cover_image_url) + VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl) RETURNING id; """, new @@ -129,7 +130,13 @@ public sealed class CreateSessionHandler( Link = command.Link, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, - MaxPlayers = command.MaxPlayers + MaxPlayers = command.MaxPlayers, + System = command.System?.ToString(), + command.Description, + command.Format, + DurationMinutes = command.DurationMinutes, + IsOneShot = command.IsOneShot, + CoverImageUrl = command.ImageReference }, transaction); @@ -137,6 +144,7 @@ public sealed class CreateSessionHandler( } await transaction.CommitAsync(ct); + transactionCommitted = true; var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty()); @@ -150,7 +158,10 @@ public sealed class CreateSessionHandler( } catch { - await transaction.RollbackAsync(ct); + if (!transactionCommitted) + { + await transaction.RollbackAsync(ct); + } throw; } } 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..4ff58c8 --- /dev/null +++ b/src/GmRelay.Shared/Features/Showcase/ShowcaseSessionDto.cs @@ -0,0 +1,20 @@ +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, + string? Description); 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 @@ - + diff --git a/src/GmRelay.Web/Components/Pages/PublicSession.razor b/src/GmRelay.Web/Components/Pages/PublicSession.razor index 3abbe13..aa7c11d 100644 --- a/src/GmRelay.Web/Components/Pages/PublicSession.razor +++ b/src/GmRelay.Web/Components/Pages/PublicSession.razor @@ -2,6 +2,9 @@ @layout PublicLayout @inject ISessionStore SessionStore @inject NavigationManager Navigation +@inject AuthenticationStateProvider AuthStateProvider +@using GmRelay.Shared.Features.Showcase +@using GmRelay.Web.Services @PageTitleText @@ -30,10 +33,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 +72,47 @@ 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 (registrationResult is not null) + { +
+

@registrationResult

+
+ } +
@if (!string.IsNullOrWhiteSpace(session.GroupSlug)) { Расписание клуба } Ссылка на сессию + @if (session.AllowDirectRegistration) + { + @if (isAuthenticated) + { + + } + else + { + Войти, чтобы записаться + } + }
} @@ -65,8 +120,10 @@ else if (session is not null) @code { [Parameter] public Guid SessionId { get; set; } - private WebPublicSession? session; + private ShowcaseSessionDto? session; private bool loaded; + private bool isAuthenticated; + private string? registrationResult; private string PageTitleText => session is null ? "Публичная сессия — GM-Relay" : $"{session.Title} — GM-Relay"; @@ -75,11 +132,52 @@ else if (session is not null) protected override async Task OnParametersSetAsync() { loaded = false; - session = await SessionStore.GetPublicSessionAsync(SessionId); + registrationResult = null; + session = await SessionStore.GetShowcaseSessionAsync(SessionId); + + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + isAuthenticated = authState.User.Identity?.IsAuthenticated ?? false; + + 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)) + { + 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 static string FormatSeats(WebPublicSession session) + 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 ? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}" @@ -90,6 +188,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/Components/Pages/Showcase.razor b/src/GmRelay.Web/Components/Pages/Showcase.razor new file mode 100644 index 0000000..c33b485 --- /dev/null +++ b/src/GmRelay.Web/Components/Pages/Showcase.razor @@ -0,0 +1,438 @@ +@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; + 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; + try + { + page++; + var results = await SessionStore.GetShowcaseSessionsAsync(filter, page, PageSize); + sessions.AddRange(results); + hasMore = results.Count == PageSize; + } + finally + { + 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 + }; +} 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..3e5f849 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,24 @@ 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, + string? Description); public sealed class SessionService( NpgsqlDataSource dataSource, @@ -362,6 +381,228 @@ 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, + s.description AS Description + 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, + r.Description)).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, + s.description AS Description + 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, + row.Description); + } + + 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 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, Cancelled = SessionStatus.Cancelled }, + transaction); + + if (session is null || !(bool)session.allowdirectregistration) + { + await transaction.RollbackAsync(); + return false; + } + + var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, 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)); + + 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 + { + SessionId = sessionId, + PlayerId = playerId, + Pending = RsvpStatus.Pending, + RegistrationStatus = registrationStatus + }, + transaction); + + if (inserted == 0) + { + await transaction.RollbackAsync(); + return false; + } + + await transaction.CommitAsync(); + return true; + } + public async Task IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) { await using var conn = await dataSource.OpenConnectionAsync(); 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; 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"))); } diff --git a/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs b/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs new file mode 100644 index 0000000..868d734 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Domain/GameSystemTests.cs @@ -0,0 +1,65 @@ +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(" 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) + { + 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")] + [InlineData((GameSystem)999, "Другое")] + public void ToDisplayName_ShouldReturnExpectedName(GameSystem system, string expected) + { + var result = system.ToDisplayName(); + + Assert.Equal(expected, result); + } +} 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/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);