feat(web): add showcase query and registration methods
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Showcase;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
@@ -91,6 +92,11 @@ public interface ISessionStore
|
|||||||
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
|
Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName);
|
||||||
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
|
Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId);
|
||||||
Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl);
|
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(
|
public sealed record LinkedIdentity(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Features.Showcase;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
@@ -110,6 +111,23 @@ internal sealed record WebBatchSessionRow(
|
|||||||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||||||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||||||
internal sealed record WebPublicGroupRow(Guid GroupId, string Name, string Slug);
|
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(
|
public sealed class SessionService(
|
||||||
NpgsqlDataSource dataSource,
|
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)
|
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
|||||||
Reference in New Issue
Block a user