feat(web): add showcase query and registration methods

This commit is contained in:
2026-05-28 15:27:18 +03:00
parent 9b42ea034a
commit b2497ed877
2 changed files with 248 additions and 0 deletions
@@ -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<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
}
public sealed record LinkedIdentity(
+242
View File
@@ -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<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.QueryAsync<ShowcaseSessionRow>(
"""
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<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var row = await conn.QuerySingleOrDefaultAsync<ShowcaseSessionRow>(
"""
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<bool> 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<dynamic>(
"""
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<bool>(
"""
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<int>(
"""
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<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();