From b2497ed877c0578ece13e29722b03cf922059fa3 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 28 May 2026 15:27:18 +0300 Subject: [PATCH] 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();