29f6f6a827
PR Checks / test-and-build (pull_request) Successful in 8m17s
Dapper.AOT generated a 19-parameter ctor for ShowcaseSessionRow based on the SELECT list in GetShowcaseSessionsAsync / GetShowcaseSessionAsync. After adding PublicationMode and IsMembersOnly to ShowcaseSessionDto in v3.7.0 the record itself was extended, but the SELECT still returned 19 columns, so the materializer threw "A parameterless default constructor or one matching signature (...) is required" and every request to /showcase returned 500. Add s.publication_mode and (s.publication_mode = 'ClubOnly') to both SELECT lists and propagate them through the ShowcaseSessionDto construction. The field list now matches the generated constructor exactly. Version bump 3.7.0 -> 3.7.1 (patch).
2751 lines
114 KiB
C#
2751 lines
114 KiB
C#
using Dapper;
|
||
using GmRelay.Shared.Domain;
|
||
using GmRelay.Shared.Features.Showcase;
|
||
using GmRelay.Shared.Rendering;
|
||
using Npgsql;
|
||
using Telegram.Bot;
|
||
using Telegram.Bot.Exceptions;
|
||
using GmRelay.Web.Services;
|
||
|
||
namespace GmRelay.Web.Services;
|
||
|
||
public sealed record WebGameGroup(
|
||
Guid Id,
|
||
long TelegramChatId,
|
||
string? ExternalGroupId,
|
||
string Name,
|
||
string? Platform,
|
||
string ManagerRole = GroupManagerRoleExtensions.OwnerValue)
|
||
{
|
||
public long GmTelegramId { get; init; }
|
||
|
||
public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId)
|
||
: this(id, telegramChatId, null, name, null)
|
||
{
|
||
GmTelegramId = gmTelegramId;
|
||
}
|
||
}
|
||
|
||
public sealed record WebGroupManager(
|
||
long TelegramId,
|
||
string? ExternalUserId,
|
||
string? Platform,
|
||
string DisplayName,
|
||
string? TelegramUsername,
|
||
string? ExternalUsername,
|
||
string Role,
|
||
DateTime AddedAt)
|
||
{
|
||
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
|
||
: this(telegramId, null, null, displayName, telegramUsername, null, role, addedAt) { }
|
||
|
||
public WebGroupManager(
|
||
long telegramId,
|
||
string? externalUserId,
|
||
string displayName,
|
||
string? telegramUsername,
|
||
string? externalUsername,
|
||
string role,
|
||
DateTime addedAt)
|
||
: this(telegramId, externalUserId, null, displayName, telegramUsername, externalUsername, role, addedAt) { }
|
||
}
|
||
|
||
public sealed record WebGroupManagement(
|
||
WebGameGroup Group,
|
||
IReadOnlyList<WebGroupManager> Managers,
|
||
bool CurrentUserIsOwner);
|
||
public sealed record WebSession(
|
||
Guid Id,
|
||
Guid GroupId,
|
||
string Title,
|
||
DateTime ScheduledAt,
|
||
string Status,
|
||
string JoinLink,
|
||
Guid BatchId,
|
||
int? BatchMessageId,
|
||
long TelegramChatId,
|
||
int? MaxPlayers,
|
||
int ActivePlayerCount,
|
||
int WaitlistedPlayerCount,
|
||
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
|
||
int? ThreadId = null,
|
||
string PublicationMode = PublicationModeExtensions.NoneValue)
|
||
{
|
||
public bool IsPublic
|
||
{
|
||
get
|
||
{
|
||
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
|
||
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
|
||
}
|
||
}
|
||
|
||
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
|
||
}
|
||
|
||
public sealed record WebParticipant(
|
||
Guid Id,
|
||
long TelegramId,
|
||
string? ExternalUserId,
|
||
string DisplayName,
|
||
string? TelegramUsername,
|
||
string? ExternalUsername,
|
||
string RsvpStatus,
|
||
string RegistrationStatus,
|
||
bool IsGm,
|
||
DateTime? RespondedAt);
|
||
|
||
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||
internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName);
|
||
internal sealed record WebBatchInfo(
|
||
Guid BatchId,
|
||
Guid GroupId,
|
||
string Title,
|
||
string JoinLink,
|
||
long TelegramChatId,
|
||
int? BatchMessageId,
|
||
int? ThreadId,
|
||
string NotificationMode);
|
||
|
||
internal sealed record WebBatchSessionRow(
|
||
Guid Id,
|
||
Guid GroupId,
|
||
string Title,
|
||
string JoinLink,
|
||
DateTime ScheduledAt,
|
||
string Status,
|
||
int? MaxPlayers,
|
||
int? BatchMessageId,
|
||
long TelegramChatId,
|
||
int? ThreadId,
|
||
string NotificationMode,
|
||
bool TopicCreatedByBot = false);
|
||
internal sealed record WebTemplateGroupDto(long TelegramChatId);
|
||
internal sealed record WebTemplateTopicDestination(int? MessageThreadId, bool TopicCreatedByBot);
|
||
internal sealed record WebPublicGroupRow(
|
||
Guid GroupId,
|
||
string Name,
|
||
string Slug,
|
||
string? MasterProfileSlug,
|
||
string? MasterDisplayName);
|
||
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,
|
||
string? MasterProfileSlug,
|
||
string? MasterDisplayName,
|
||
string PublicationMode = "None",
|
||
bool IsMembersOnly = false);
|
||
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
|
||
|
||
public sealed class SessionService(
|
||
NpgsqlDataSource dataSource,
|
||
ITelegramBotClient bot,
|
||
ILogger<SessionService> logger) : ISessionStore
|
||
{
|
||
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||
if (playerIds.Length == 0)
|
||
return [];
|
||
|
||
return (await conn.QueryAsync<WebGameGroup>(
|
||
"""
|
||
WITH visible_groups AS (
|
||
SELECT gm.group_id,
|
||
CASE
|
||
WHEN bool_or(gm.role = @OwnerRole) THEN @OwnerRole
|
||
ELSE @CoGmRole
|
||
END AS ManagerRole
|
||
FROM group_managers gm
|
||
WHERE gm.player_id = ANY(@PlayerIds)
|
||
GROUP BY gm.group_id
|
||
)
|
||
SELECT g.id,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
g.external_group_id AS ExternalGroupId,
|
||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||
g.platform AS Platform,
|
||
vg.ManagerRole
|
||
FROM visible_groups vg
|
||
JOIN game_groups g ON g.id = vg.group_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.title
|
||
FROM sessions s
|
||
WHERE s.group_id = g.id
|
||
ORDER BY s.scheduled_at DESC
|
||
LIMIT 1
|
||
) latest_session ON true
|
||
ORDER BY g.name
|
||
""",
|
||
new
|
||
{
|
||
PlayerIds = playerIds,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||
})).ToList();
|
||
}
|
||
|
||
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebGameGroup>(
|
||
"""
|
||
SELECT g.id,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
g.external_group_id AS ExternalGroupId,
|
||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||
g.platform AS Platform,
|
||
@OwnerRole AS ManagerRole
|
||
FROM game_groups g
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.title
|
||
FROM sessions s
|
||
WHERE s.group_id = g.id
|
||
ORDER BY s.scheduled_at DESC
|
||
LIMIT 1
|
||
) latest_session ON true
|
||
WHERE g.id = @GroupId
|
||
""",
|
||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||
}
|
||
|
||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebPublicGroupSettings>(
|
||
"""
|
||
SELECT g.id AS GroupId,
|
||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS GroupName,
|
||
g.public_slug AS PublicSlug,
|
||
g.public_schedule_enabled AS PublicScheduleEnabled,
|
||
COALESCE(public_counts.count, 0)::int AS PublicSessionCount
|
||
FROM game_groups g
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.title
|
||
FROM sessions s
|
||
WHERE s.group_id = g.id
|
||
ORDER BY s.scheduled_at DESC
|
||
LIMIT 1
|
||
) latest_session ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT COUNT(*) AS count
|
||
FROM sessions s
|
||
WHERE s.group_id = g.id
|
||
AND s.publication_mode IN ('Catalog', 'Both')
|
||
) public_counts ON true
|
||
WHERE g.id = @GroupId
|
||
""",
|
||
new { GroupId = groupId });
|
||
}
|
||
|
||
public async Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
try
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE game_groups
|
||
SET public_slug = @PublicSlug,
|
||
public_schedule_enabled = @PublicScheduleEnabled,
|
||
public_schedule_updated_at = now()
|
||
WHERE id = @GroupId
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
|
||
PublicScheduleEnabled = publicScheduleEnabled
|
||
});
|
||
}
|
||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||
{
|
||
throw new InvalidOperationException("Public slug is already in use.", ex);
|
||
}
|
||
}
|
||
|
||
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET publication_mode = @Mode,
|
||
updated_at = now()
|
||
WHERE id = @SessionId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, "0");
|
||
}
|
||
}
|
||
|
||
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET publication_mode = @Mode,
|
||
updated_at = now()
|
||
WHERE batch_id = @BatchId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
}
|
||
|
||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
|
||
"""
|
||
SELECT g.id AS GroupId,
|
||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||
g.public_slug AS Slug,
|
||
mp.public_slug AS MasterProfileSlug,
|
||
mp.display_name AS MasterDisplayName
|
||
FROM game_groups g
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.title
|
||
FROM sessions s
|
||
WHERE s.group_id = g.id
|
||
ORDER BY s.scheduled_at DESC
|
||
LIMIT 1
|
||
) latest_session ON true
|
||
LEFT JOIN LATERAL (
|
||
SELECT gm.player_id
|
||
FROM group_managers gm
|
||
WHERE gm.group_id = g.id
|
||
AND gm.role = @OwnerRole
|
||
ORDER BY gm.created_at
|
||
LIMIT 1
|
||
) owner_manager ON true
|
||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||
AND mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
WHERE g.public_schedule_enabled = true
|
||
AND g.public_slug IS NOT NULL
|
||
AND lower(g.public_slug) = lower(@Slug)
|
||
""",
|
||
new { Slug = slug, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||
|
||
if (group is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
|
||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||
}
|
||
|
||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
|
||
"""
|
||
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.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.publication_mode AS PublicationMode,
|
||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||
mp.public_slug AS MasterProfileSlug,
|
||
mp.display_name AS MasterDisplayName
|
||
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
|
||
LEFT JOIN LATERAL (
|
||
SELECT gm.player_id
|
||
FROM group_managers gm
|
||
WHERE gm.group_id = g.id
|
||
AND gm.role = @OwnerRole
|
||
ORDER BY gm.created_at
|
||
LIMIT 1
|
||
) owner_manager ON true
|
||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||
AND mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
WHERE s.id = @SessionId
|
||
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
|
||
AND (
|
||
s.publication_mode IN ('Catalog', 'Both')
|
||
OR (
|
||
s.publication_mode = 'ClubOnly'
|
||
AND @ViewerPlayerId IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1 FROM club_memberships cm
|
||
WHERE cm.group_id = s.group_id
|
||
AND cm.player_id = @ViewerPlayerId
|
||
AND cm.status = 'Active'
|
||
)
|
||
)
|
||
)
|
||
""",
|
||
new
|
||
{
|
||
SessionId = sessionId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||
ViewerPlayerId = viewerPlayerId
|
||
});
|
||
}
|
||
|
||
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,
|
||
s.description AS Description,
|
||
mp.public_slug AS MasterProfileSlug,
|
||
mp.display_name AS MasterDisplayName,
|
||
s.publication_mode AS PublicationMode,
|
||
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
|
||
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
|
||
LEFT JOIN LATERAL (
|
||
SELECT gm.player_id
|
||
FROM group_managers gm
|
||
WHERE gm.group_id = g.id
|
||
AND gm.role = @OwnerRole
|
||
ORDER BY gm.created_at
|
||
LIMIT 1
|
||
) owner_manager ON true
|
||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||
AND mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
WHERE g.public_schedule_enabled = true
|
||
AND g.public_slug IS NOT NULL
|
||
AND s.publication_mode IN ('Catalog', 'Both')
|
||
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,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||
});
|
||
|
||
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,
|
||
PublicationMode: r.PublicationMode,
|
||
IsMembersOnly: r.IsMembersOnly,
|
||
r.MasterProfileSlug, r.MasterDisplayName)).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,
|
||
s.description AS Description,
|
||
mp.public_slug AS MasterProfileSlug,
|
||
mp.display_name AS MasterDisplayName,
|
||
s.publication_mode AS PublicationMode,
|
||
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
|
||
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
|
||
LEFT JOIN LATERAL (
|
||
SELECT gm.player_id
|
||
FROM group_managers gm
|
||
WHERE gm.group_id = g.id
|
||
AND gm.role = @OwnerRole
|
||
ORDER BY gm.created_at
|
||
LIMIT 1
|
||
) owner_manager ON true
|
||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||
AND mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
WHERE s.id = @SessionId
|
||
AND g.public_schedule_enabled = true
|
||
AND g.public_slug IS NOT NULL
|
||
AND s.publication_mode IN ('Catalog', 'Both')
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
""",
|
||
new
|
||
{
|
||
SessionId = sessionId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||
});
|
||
|
||
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,
|
||
PublicationMode: row.PublicationMode,
|
||
IsMembersOnly: row.IsMembersOnly,
|
||
row.MasterProfileSlug, row.MasterDisplayName);
|
||
}
|
||
|
||
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 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.publication_mode IN ('Catalog', 'Both')
|
||
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<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 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<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||
if (playerIds.Length == 0)
|
||
return false;
|
||
|
||
return await conn.ExecuteScalarAsync<bool>(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM group_managers
|
||
WHERE group_id = @GroupId
|
||
AND player_id = ANY(@PlayerIds)
|
||
)
|
||
""",
|
||
new { GroupId = groupId, PlayerIds = playerIds });
|
||
}
|
||
|
||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var playerIds = await _ResolveLinkedPlayerIdsAsync(conn, platform, externalUserId);
|
||
if (playerIds.Length == 0)
|
||
return false;
|
||
|
||
return await conn.ExecuteScalarAsync<bool>(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1
|
||
FROM group_managers
|
||
WHERE group_id = @GroupId
|
||
AND player_id = ANY(@PlayerIds)
|
||
AND role = @OwnerRole
|
||
)
|
||
""",
|
||
new { GroupId = groupId, PlayerIds = playerIds, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||
}
|
||
|
||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebGroupManager>(
|
||
"""
|
||
SELECT CASE
|
||
WHEN p.platform = 'Telegram' AND p.external_user_id ~ '^[0-9]+$'
|
||
THEN p.external_user_id::BIGINT
|
||
ELSE 0
|
||
END AS TelegramId,
|
||
p.external_user_id AS ExternalUserId,
|
||
p.platform AS Platform,
|
||
p.display_name AS DisplayName,
|
||
p.external_username AS TelegramUsername,
|
||
p.external_username AS ExternalUsername,
|
||
gm.role AS Role,
|
||
gm.created_at AS AddedAt
|
||
FROM group_managers gm
|
||
JOIN players p ON p.id = gm.player_id
|
||
WHERE gm.group_id = @GroupId
|
||
ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END,
|
||
gm.created_at,
|
||
p.display_name
|
||
""",
|
||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList();
|
||
}
|
||
|
||
public async Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<PlayerAttendanceStats>(
|
||
"""
|
||
SELECT
|
||
p.id AS PlayerId,
|
||
p.display_name AS DisplayName,
|
||
p.external_username AS ExternalUsername,
|
||
COUNT(DISTINCT s.id) AS TotalSessions,
|
||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
|
||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
|
||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Pending' THEN s.id END) AS NoResponseCount,
|
||
COUNT(DISTINCT CASE WHEN sp.registration_status = 'Waitlisted' THEN s.id END) AS WaitlistedCount,
|
||
COUNT(DISTINCT CASE WHEN s.status = 'Cancelled' AND sp.rsvp_status IN ('Confirmed','Declined') THEN s.id END) AS CancellationAffectedCount,
|
||
CASE WHEN COUNT(DISTINCT s.id) > 0
|
||
THEN ROUND(
|
||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END)
|
||
* 100.0 / COUNT(DISTINCT s.id), 2)
|
||
ELSE 0
|
||
END AS AttendanceRate
|
||
FROM players p
|
||
JOIN session_participants sp ON sp.player_id = p.id
|
||
JOIN sessions s ON s.id = sp.session_id
|
||
WHERE s.group_id = @GroupId
|
||
AND s.scheduled_at <= now()
|
||
AND sp.is_gm = false
|
||
GROUP BY p.id, p.display_name, p.external_username
|
||
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
|
||
""",
|
||
new { GroupId = groupId })).ToList();
|
||
}
|
||
|
||
public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value)
|
||
VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue)
|
||
""",
|
||
new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
|
||
}
|
||
|
||
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var entries = await conn.QueryAsync<SessionAuditLogEntry>(
|
||
"""
|
||
SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName,
|
||
change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
|
||
FROM session_audit_log
|
||
WHERE session_id = @SessionId
|
||
ORDER BY changed_at DESC
|
||
""",
|
||
new { SessionId = sessionId });
|
||
return entries.ToList();
|
||
}
|
||
|
||
public async Task AddGroupCoGmAsync(
|
||
Guid groupId,
|
||
string ownerPlatform, string ownerExternalUserId,
|
||
string coGmPlatform, string coGmExternalUserId,
|
||
string displayName, string? externalUsername)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var ownerPlayerId = await _ResolveEffectivePlayerIdAsync(conn, ownerPlatform, ownerExternalUserId);
|
||
if (ownerPlayerId is null)
|
||
throw new InvalidOperationException("Owner player not found.");
|
||
|
||
var coGmPlayerId = await _UpsertPlayerAndGetIdAsync(conn, coGmPlatform, coGmExternalUserId, displayName, externalUsername, transaction);
|
||
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO group_managers (group_id, player_id, role, added_by_player_id)
|
||
VALUES (@GroupId, @CoGmPlayerId, @CoGmRole, @OwnerPlayerId)
|
||
ON CONFLICT (group_id, player_id) DO UPDATE
|
||
SET role = CASE
|
||
WHEN group_managers.role = @OwnerRole THEN group_managers.role
|
||
ELSE EXCLUDED.role
|
||
END,
|
||
added_by_player_id = EXCLUDED.added_by_player_id
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
OwnerPlayerId = ownerPlayerId.Value,
|
||
CoGmPlayerId = coGmPlayerId,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||
},
|
||
transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
}
|
||
|
||
public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var coGmPlayerId = await _ResolveEffectivePlayerIdAsync(conn, coGmPlatform, coGmExternalUserId);
|
||
if (coGmPlayerId is null)
|
||
return;
|
||
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
DELETE FROM group_managers
|
||
WHERE group_id = @GroupId
|
||
AND player_id = @PlayerId
|
||
AND role = @CoGmRole
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
PlayerId = coGmPlayerId.Value,
|
||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||
});
|
||
}
|
||
|
||
public async Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode,
|
||
s.thread_id AS ThreadId,
|
||
s.publication_mode AS PublicationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
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.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours'
|
||
ORDER BY s.scheduled_at",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||
})).ToList();
|
||
}
|
||
|
||
public async Task<WebSession?> GetSessionAsync(Guid sessionId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode,
|
||
s.thread_id AS ThreadId,
|
||
s.publication_mode AS PublicationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
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",
|
||
new
|
||
{
|
||
SessionId = sessionId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||
});
|
||
}
|
||
|
||
public async Task<WebSessionBatch?> GetBatchAsync(Guid batchId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebSessionBatch>(
|
||
"""
|
||
SELECT s.batch_id AS Id,
|
||
s.group_id AS GroupId,
|
||
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
||
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
||
MIN(s.scheduled_at) AS FirstScheduledAt,
|
||
MAX(s.scheduled_at) AS LastScheduledAt,
|
||
COUNT(*)::int AS SessionCount,
|
||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||
FROM sessions s
|
||
WHERE s.batch_id = @BatchId
|
||
GROUP BY s.batch_id, s.group_id
|
||
""",
|
||
new { BatchId = batchId });
|
||
}
|
||
|
||
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
0 AS ActivePlayerCount,
|
||
0 AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode,
|
||
s.thread_id AS ThreadId,
|
||
s.publication_mode AS PublicationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||
new { Id = sessionId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (oldSession is null)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, "0");
|
||
}
|
||
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
@"UPDATE sessions
|
||
SET title = @Title,
|
||
scheduled_at = @ScheduledAt,
|
||
join_link = @JoinLink,
|
||
max_players = @MaxPlayers,
|
||
one_hour_reminder_processed_at = CASE
|
||
WHEN scheduled_at <> @ScheduledAt THEN NULL
|
||
ELSE one_hour_reminder_processed_at
|
||
END,
|
||
updated_at = now()
|
||
WHERE id = @Id AND group_id = @GroupId",
|
||
new
|
||
{
|
||
Id = sessionId,
|
||
GroupId = groupId,
|
||
Title = title,
|
||
ScheduledAt = scheduledAt,
|
||
JoinLink = joinLink,
|
||
MaxPlayers = maxPlayers
|
||
},
|
||
transaction);
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, "0");
|
||
}
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET title = @Title WHERE batch_id = @BatchId",
|
||
new { Title = title, BatchId = oldSession.BatchId },
|
||
transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var timeChanged = oldSession.ScheduledAt != scheduledAt;
|
||
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
|
||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
|
||
$"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : "") +
|
||
"\n" +
|
||
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
|
||
|
||
await bot.SendMessage(
|
||
chatId: oldSession.TelegramChatId,
|
||
messageThreadId: oldSession.ThreadId,
|
||
text: notification,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode);
|
||
if (mode.ShouldSendDirectMessages())
|
||
{
|
||
var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId);
|
||
await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId);
|
||
}
|
||
|
||
if (oldSession.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title);
|
||
}
|
||
}
|
||
|
||
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
0 AS ActivePlayerCount,
|
||
0 AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode,
|
||
s.thread_id AS ThreadId,
|
||
s.publication_mode AS PublicationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||
FOR UPDATE",
|
||
new { SessionId = sessionId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (session is null)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, "0");
|
||
}
|
||
|
||
var activeParticipants = 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 waitlistedParticipants = await conn.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)
|
||
FROM session_participants
|
||
WHERE session_id = @SessionId
|
||
AND is_gm = false
|
||
AND registration_status = @Waitlisted
|
||
""",
|
||
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||
{
|
||
throw new InvalidOperationException(waitlistedParticipants == 0
|
||
? "Лист ожидания пуст."
|
||
: "Нет свободных мест для повышения игрока.");
|
||
}
|
||
|
||
var promoted = await conn.QuerySingleAsync<WebPromotedParticipantDto>(
|
||
"""
|
||
SELECT sp.id AS ParticipantRowId,
|
||
p.display_name AS DisplayName
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Waitlisted
|
||
ORDER BY sp.created_at ASC, sp.id ASC
|
||
LIMIT 1
|
||
FOR UPDATE OF sp
|
||
""",
|
||
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE session_participants
|
||
SET registration_status = @Active,
|
||
rsvp_status = @Pending,
|
||
responded_at = NULL
|
||
WHERE id = @ParticipantRowId
|
||
""",
|
||
new
|
||
{
|
||
promoted.ParticipantRowId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Pending = RsvpStatus.Pending
|
||
},
|
||
transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
await bot.SendMessage(
|
||
chatId: session.TelegramChatId,
|
||
messageThreadId: session.ThreadId,
|
||
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
if (session.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||
}
|
||
}
|
||
|
||
public async Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebParticipant>(
|
||
"""
|
||
SELECT sp.id AS Id,
|
||
COALESCE(p.external_user_id::BIGINT, 0) AS TelegramId,
|
||
p.external_user_id AS ExternalUserId,
|
||
p.display_name AS DisplayName,
|
||
p.external_username AS TelegramUsername,
|
||
p.external_username AS ExternalUsername,
|
||
sp.rsvp_status AS RsvpStatus,
|
||
sp.registration_status AS RegistrationStatus,
|
||
sp.is_gm AS IsGm,
|
||
sp.responded_at AS RespondedAt
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId
|
||
ORDER BY sp.is_gm DESC,
|
||
CASE sp.registration_status WHEN 'Active' THEN 0 ELSE 1 END,
|
||
sp.created_at
|
||
""",
|
||
new { SessionId = sessionId })).ToList();
|
||
}
|
||
|
||
public async Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.max_players AS MaxPlayers,
|
||
0 AS ActivePlayerCount,
|
||
0 AS WaitlistedPlayerCount,
|
||
s.notification_mode AS NotificationMode,
|
||
s.thread_id AS ThreadId,
|
||
s.publication_mode AS PublicationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||
FOR UPDATE",
|
||
new { SessionId = sessionId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (session is null)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, "0");
|
||
}
|
||
|
||
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
||
"""
|
||
SELECT sp.id AS Id,
|
||
p.external_user_id::BIGINT AS TelegramId,
|
||
p.display_name AS DisplayName,
|
||
p.external_username AS TelegramUsername,
|
||
sp.rsvp_status AS RsvpStatus,
|
||
sp.registration_status AS RegistrationStatus,
|
||
sp.is_gm AS IsGm,
|
||
sp.responded_at AS RespondedAt
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.id = @ParticipantId AND sp.session_id = @SessionId
|
||
""",
|
||
new { ParticipantId = participantId, SessionId = sessionId },
|
||
transaction);
|
||
|
||
if (participant is null)
|
||
{
|
||
throw new InvalidOperationException("Участник не найден в этой сессии.");
|
||
}
|
||
|
||
bool wasActive = participant.RegistrationStatus == ParticipantRegistrationStatus.Active;
|
||
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM session_participants WHERE id = @ParticipantId",
|
||
new { ParticipantId = participantId },
|
||
transaction);
|
||
|
||
WebPromotedParticipantDto? promoted = null;
|
||
|
||
if (wasActive)
|
||
{
|
||
promoted = await conn.QuerySingleOrDefaultAsync<WebPromotedParticipantDto>(
|
||
"""
|
||
SELECT sp.id AS ParticipantRowId,
|
||
p.display_name AS DisplayName
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Waitlisted
|
||
ORDER BY sp.created_at ASC, sp.id ASC
|
||
LIMIT 1
|
||
FOR UPDATE OF sp
|
||
""",
|
||
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||
transaction);
|
||
|
||
if (promoted is not null)
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE session_participants
|
||
SET registration_status = @Active,
|
||
rsvp_status = @Pending,
|
||
responded_at = NULL
|
||
WHERE id = @ParticipantRowId
|
||
""",
|
||
new
|
||
{
|
||
promoted.ParticipantRowId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Pending = RsvpStatus.Pending
|
||
},
|
||
transaction);
|
||
}
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
await bot.SendMessage(
|
||
chatId: session.TelegramChatId,
|
||
messageThreadId: session.ThreadId,
|
||
text: $"🚪 <b>{System.Net.WebUtility.HtmlEncode(participant.DisplayName)}</b> удален(а) из сессии «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
if (promoted is not null)
|
||
{
|
||
await bot.SendMessage(
|
||
chatId: session.TelegramChatId,
|
||
messageThreadId: session.ThreadId,
|
||
text: $"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
if (session.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||
}
|
||
}
|
||
else if (session.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||
}
|
||
}
|
||
|
||
public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
||
if (batch is null)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET title = @Title,
|
||
join_link = @JoinLink,
|
||
updated_at = now()
|
||
WHERE batch_id = @BatchId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new
|
||
{
|
||
BatchId = batchId,
|
||
GroupId = groupId,
|
||
Title = title,
|
||
JoinLink = joinLink
|
||
},
|
||
transaction);
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
if (batch.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title);
|
||
}
|
||
}
|
||
|
||
public async Task UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
||
if (batch is null)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET notification_mode = @NotificationMode,
|
||
updated_at = now()
|
||
WHERE batch_id = @BatchId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new
|
||
{
|
||
BatchId = batchId,
|
||
GroupId = groupId,
|
||
NotificationMode = notificationMode.ToDatabaseValue()
|
||
},
|
||
transaction);
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
}
|
||
|
||
public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var batchSessions = (await conn.QueryAsync<WebBatchSessionRow>(
|
||
"""
|
||
SELECT s.id AS Id,
|
||
s.group_id AS GroupId,
|
||
s.title AS Title,
|
||
s.join_link AS JoinLink,
|
||
s.scheduled_at AS ScheduledAt,
|
||
s.status AS Status,
|
||
s.max_players AS MaxPlayers,
|
||
s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.thread_id AS ThreadId,
|
||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND s.group_id = @GroupId
|
||
ORDER BY s.scheduled_at
|
||
FOR UPDATE
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId },
|
||
transaction)).ToList();
|
||
|
||
if (batchSessions.Count == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
|
||
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
|
||
batchSessions.Select(session => session.ScheduledAt),
|
||
firstScheduledAt,
|
||
intervalDays);
|
||
|
||
for (var index = 0; index < batchSessions.Count; index++)
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET scheduled_at = @ScheduledAt,
|
||
one_hour_reminder_processed_at = NULL,
|
||
updated_at = now()
|
||
WHERE id = @SessionId
|
||
""",
|
||
new
|
||
{
|
||
SessionId = batchSessions[index].Id,
|
||
ScheduledAt = newSchedule[index]
|
||
},
|
||
transaction);
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var firstSession = batchSessions[0];
|
||
if (firstSession.BatchMessageId.HasValue)
|
||
{
|
||
await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title);
|
||
}
|
||
|
||
var notification = $"🔄 <b>Мастер обновил расписание пачки</b>\n\n" +
|
||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(firstSession.Title)}</b>\n" +
|
||
$"🗓 Новое начало: <b>{firstScheduledAt.FormatMoscow()}</b> (МСК)\n" +
|
||
$"↔️ Шаг: <b>{intervalDays} дн.</b>";
|
||
|
||
await bot.SendMessage(
|
||
chatId: firstSession.TelegramChatId,
|
||
messageThreadId: firstSession.ThreadId,
|
||
text: notification,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
|
||
var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode);
|
||
if (mode.ShouldSendDirectMessages())
|
||
{
|
||
var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId);
|
||
await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId);
|
||
}
|
||
}
|
||
|
||
public async Task<WebSessionBatch> CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var sourceSessions = (await conn.QueryAsync<WebBatchSessionRow>(
|
||
"""
|
||
SELECT s.id AS Id,
|
||
s.group_id AS GroupId,
|
||
s.title AS Title,
|
||
s.join_link AS JoinLink,
|
||
s.scheduled_at AS ScheduledAt,
|
||
s.status AS Status,
|
||
s.max_players AS MaxPlayers,
|
||
s.batch_message_id AS BatchMessageId,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
s.thread_id AS ThreadId,
|
||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||
s.notification_mode AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND s.group_id = @GroupId
|
||
ORDER BY s.scheduled_at
|
||
FOR UPDATE
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId },
|
||
transaction)).ToList();
|
||
|
||
if (sourceSessions.Count == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
|
||
var newBatchId = Guid.NewGuid();
|
||
var batchTitle = sourceSessions[0].Title;
|
||
var batchJoinLink = sourceSessions[0].JoinLink;
|
||
var chatId = sourceSessions[0].TelegramChatId;
|
||
var threadId = sourceSessions[0].ThreadId;
|
||
var renderedSessions = new List<SessionBatchDto>();
|
||
|
||
foreach (var sourceSession in sourceSessions)
|
||
{
|
||
var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval);
|
||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||
"""
|
||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
|
||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
||
RETURNING id
|
||
""",
|
||
new
|
||
{
|
||
BatchId = newBatchId,
|
||
sourceSession.GroupId,
|
||
Title = batchTitle,
|
||
JoinLink = batchJoinLink,
|
||
ScheduledAt = scheduledAt,
|
||
Status = SessionStatus.Planned,
|
||
ThreadId = threadId,
|
||
sourceSession.TopicCreatedByBot,
|
||
sourceSession.MaxPlayers,
|
||
sourceSession.NotificationMode
|
||
},
|
||
transaction);
|
||
|
||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var view = SessionBatchViewBuilder.Build(batchTitle, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||
var batchMessage = await bot.SendMessage(
|
||
chatId: chatId,
|
||
messageThreadId: threadId,
|
||
text: renderResult.Text,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: renderResult.Markup);
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
|
||
new { MessageId = batchMessage.MessageId, BatchId = newBatchId });
|
||
|
||
return new WebSessionBatch(
|
||
newBatchId,
|
||
groupId,
|
||
batchTitle,
|
||
batchJoinLink,
|
||
renderedSessions.Min(session => session.ScheduledAt),
|
||
renderedSessions.Max(session => session.ScheduledAt),
|
||
renderedSessions.Count,
|
||
sourceSessions[0].NotificationMode);
|
||
}
|
||
|
||
public async Task<List<WebCampaignTemplate>> GetCampaignTemplatesAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebCampaignTemplate>(
|
||
"""
|
||
SELECT id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
FROM campaign_templates
|
||
WHERE group_id = @GroupId
|
||
ORDER BY created_at DESC, name
|
||
""",
|
||
new { GroupId = groupId })).ToList();
|
||
}
|
||
|
||
public async Task<WebCampaignTemplate?> GetCampaignTemplateAsync(Guid templateId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
|
||
"""
|
||
SELECT id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
FROM campaign_templates
|
||
WHERE id = @TemplateId
|
||
""",
|
||
new { TemplateId = templateId });
|
||
}
|
||
|
||
public async Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleAsync<WebCampaignTemplate>(
|
||
"""
|
||
INSERT INTO campaign_templates (
|
||
group_id,
|
||
name,
|
||
title,
|
||
join_link,
|
||
session_count,
|
||
interval_days,
|
||
max_players,
|
||
notification_mode
|
||
)
|
||
VALUES (
|
||
@GroupId,
|
||
@Name,
|
||
@Title,
|
||
@JoinLink,
|
||
@SessionCount,
|
||
@IntervalDays,
|
||
@MaxPlayers,
|
||
@NotificationMode
|
||
)
|
||
ON CONFLICT (group_id, name) DO UPDATE
|
||
SET title = EXCLUDED.title,
|
||
join_link = EXCLUDED.join_link,
|
||
session_count = EXCLUDED.session_count,
|
||
interval_days = EXCLUDED.interval_days,
|
||
max_players = EXCLUDED.max_players,
|
||
notification_mode = EXCLUDED.notification_mode,
|
||
updated_at = now()
|
||
RETURNING id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
request.Name,
|
||
request.Title,
|
||
request.JoinLink,
|
||
request.SessionCount,
|
||
request.IntervalDays,
|
||
request.MaxPlayers,
|
||
NotificationMode = request.NotificationMode.ToDatabaseValue()
|
||
});
|
||
}
|
||
|
||
public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await conn.ExecuteAsync(
|
||
"DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId",
|
||
new { TemplateId = templateId, GroupId = groupId });
|
||
}
|
||
|
||
public async Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var template = await conn.QuerySingleOrDefaultAsync<WebCampaignTemplate>(
|
||
"""
|
||
SELECT id AS Id,
|
||
group_id AS GroupId,
|
||
name AS Name,
|
||
title AS Title,
|
||
join_link AS JoinLink,
|
||
session_count AS SessionCount,
|
||
interval_days AS IntervalDays,
|
||
max_players AS MaxPlayers,
|
||
notification_mode AS NotificationMode,
|
||
created_at AS CreatedAt,
|
||
updated_at AS UpdatedAt
|
||
FROM campaign_templates
|
||
WHERE id = @TemplateId
|
||
AND group_id = @GroupId
|
||
FOR UPDATE
|
||
""",
|
||
new { TemplateId = templateId, GroupId = groupId },
|
||
transaction);
|
||
|
||
if (template is null)
|
||
{
|
||
throw new SessionAccessDeniedException(templateId, "0");
|
||
}
|
||
|
||
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
||
"SELECT external_group_id::BIGINT AS TelegramChatId FROM game_groups WHERE id = @GroupId",
|
||
new { GroupId = groupId },
|
||
transaction);
|
||
|
||
if (group is null)
|
||
{
|
||
throw new SessionAccessDeniedException(groupId, "0");
|
||
}
|
||
|
||
var topicDestination = await ResolveTemplateBatchTopicAsync(group.TelegramChatId, template.Title);
|
||
var messageThreadId = topicDestination.MessageThreadId;
|
||
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
|
||
|
||
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
|
||
firstScheduledAt,
|
||
template.SessionCount,
|
||
template.IntervalDays);
|
||
var batchId = Guid.NewGuid();
|
||
var renderedSessions = new List<SessionBatchDto>();
|
||
|
||
foreach (var scheduledAt in schedule)
|
||
{
|
||
var sessionId = await conn.ExecuteScalarAsync<Guid>(
|
||
"""
|
||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players, notification_mode)
|
||
VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers, @NotificationMode)
|
||
RETURNING id
|
||
""",
|
||
new
|
||
{
|
||
BatchId = batchId,
|
||
GroupId = groupId,
|
||
template.Title,
|
||
template.JoinLink,
|
||
ScheduledAt = scheduledAt,
|
||
Status = SessionStatus.Planned,
|
||
ThreadId = messageThreadId,
|
||
TopicCreatedByBot = topicCreatedByBot,
|
||
template.MaxPlayers,
|
||
template.NotificationMode
|
||
},
|
||
transaction);
|
||
|
||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
|
||
}
|
||
|
||
await transaction.CommitAsync();
|
||
|
||
var view = SessionBatchViewBuilder.Build(template.Title, renderedSessions, Array.Empty<ParticipantBatchDto>());
|
||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||
var batchMessage = await bot.SendMessage(
|
||
chatId: group.TelegramChatId,
|
||
messageThreadId: messageThreadId,
|
||
text: renderResult.Text,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
|
||
replyMarkup: renderResult.Markup);
|
||
|
||
await conn.ExecuteAsync(
|
||
"UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId",
|
||
new { MessageId = batchMessage.MessageId, BatchId = batchId });
|
||
|
||
return new WebSessionBatch(
|
||
batchId,
|
||
groupId,
|
||
template.Title,
|
||
template.JoinLink,
|
||
renderedSessions.Min(session => session.ScheduledAt),
|
||
renderedSessions.Max(session => session.ScheduledAt),
|
||
renderedSessions.Count,
|
||
template.NotificationMode);
|
||
}
|
||
|
||
private async Task<WebTemplateTopicDestination> ResolveTemplateBatchTopicAsync(long telegramChatId, string title)
|
||
{
|
||
var chat = await bot.GetChat(chatId: telegramChatId);
|
||
if (!chat.IsForum)
|
||
{
|
||
return new WebTemplateTopicDestination(null, TopicCreatedByBot: false);
|
||
}
|
||
|
||
try
|
||
{
|
||
var topic = await bot.CreateForumTopic(
|
||
chatId: telegramChatId,
|
||
name: $"🎲 Игры: {title}");
|
||
return new WebTemplateTopicDestination(topic.MessageThreadId, TopicCreatedByBot: true);
|
||
}
|
||
catch (ApiRequestException ex) when (IsMissingForumTopicRightsError(ex.Message))
|
||
{
|
||
throw new InvalidOperationException(
|
||
"Не удалось создать Telegram topic. Сделайте бота admin и включите право Manage Topics, затем повторите действие.",
|
||
ex);
|
||
}
|
||
}
|
||
|
||
private static bool IsMissingForumTopicRightsError(string apiError) =>
|
||
apiError.Contains("not enough rights", StringComparison.OrdinalIgnoreCase) ||
|
||
apiError.Contains("CHAT_ADMIN_REQUIRED", StringComparison.OrdinalIgnoreCase) ||
|
||
apiError.Contains("not an administrator", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private async Task<List<WebDirectNotificationRecipient>> LoadSessionDirectRecipientsAsync(
|
||
Npgsql.NpgsqlConnection conn,
|
||
Guid sessionId)
|
||
{
|
||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||
"""
|
||
SELECT p.external_user_id::BIGINT AS TelegramId,
|
||
p.display_name AS DisplayName
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
WHERE sp.session_id = @SessionId
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Active
|
||
""",
|
||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||
}
|
||
|
||
private async Task<List<WebDirectNotificationRecipient>> LoadBatchDirectRecipientsAsync(
|
||
Npgsql.NpgsqlConnection conn,
|
||
Guid batchId)
|
||
{
|
||
return (await conn.QueryAsync<WebDirectNotificationRecipient>(
|
||
"""
|
||
SELECT DISTINCT p.external_user_id::BIGINT AS TelegramId,
|
||
p.display_name AS DisplayName
|
||
FROM session_participants sp
|
||
JOIN players p ON p.id = sp.player_id
|
||
JOIN sessions s ON s.id = sp.session_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND sp.is_gm = false
|
||
AND sp.registration_status = @Active
|
||
""",
|
||
new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||
}
|
||
|
||
private async Task SendDirectNotificationsAsync(
|
||
IEnumerable<WebDirectNotificationRecipient> recipients,
|
||
string htmlText,
|
||
string notificationKind,
|
||
Guid entityId)
|
||
{
|
||
foreach (var recipient in recipients)
|
||
{
|
||
try
|
||
{
|
||
await bot.SendMessage(
|
||
chatId: recipient.TelegramId,
|
||
text: htmlText,
|
||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(
|
||
ex,
|
||
"Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})",
|
||
notificationKind,
|
||
entityId,
|
||
recipient.TelegramId,
|
||
recipient.DisplayName);
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
|
||
{
|
||
try
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||
new { BatchId = batchId })).ToList();
|
||
|
||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||
@"SELECT sp.session_id AS SessionId,
|
||
p.display_name AS DisplayName,
|
||
p.external_username AS TelegramUsername,
|
||
sp.registration_status AS RegistrationStatus
|
||
FROM session_participants sp
|
||
JOIN players p ON sp.player_id = p.id
|
||
JOIN sessions s ON sp.session_id = s.id
|
||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||
new { BatchId = batchId })).ToList();
|
||
|
||
var view = SessionBatchViewBuilder.Build(title, sessions, participants);
|
||
var renderResult = TelegramSessionBatchRenderer.Render(view);
|
||
|
||
await BatchMessageEditor.EditBatchMessageAsync(
|
||
bot,
|
||
chatId: chatId,
|
||
messageId: messageId,
|
||
text: renderResult.Text,
|
||
replyMarkup: renderResult.Markup);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId);
|
||
}
|
||
}
|
||
|
||
public async Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||
if (effectiveId is null)
|
||
return null;
|
||
|
||
return await conn.QuerySingleOrDefaultAsync<MasterProfileSettings>(
|
||
"""
|
||
SELECT p.id AS PlayerId,
|
||
COALESCE(mp.display_name, p.display_name) AS DisplayName,
|
||
mp.public_slug AS PublicSlug,
|
||
COALESCE(mp.is_public, false) AS IsPublic,
|
||
mp.bio AS Bio
|
||
FROM players p
|
||
LEFT JOIN master_profiles mp ON mp.player_id = p.id
|
||
WHERE p.id = @PlayerId
|
||
""",
|
||
new { PlayerId = effectiveId.Value });
|
||
}
|
||
|
||
public async Task UpdateMasterProfileSettingsAsync(
|
||
string platform,
|
||
string externalUserId,
|
||
string? publicSlug,
|
||
bool isPublic,
|
||
string displayName,
|
||
string? bio)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||
if (effectiveId is null)
|
||
throw new InvalidOperationException("Current player not found.");
|
||
|
||
try
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO master_profiles (player_id, public_slug, is_public, display_name, bio)
|
||
VALUES (@PlayerId, @PublicSlug, @IsPublic, @DisplayName, @Bio)
|
||
ON CONFLICT (player_id) DO UPDATE
|
||
SET public_slug = EXCLUDED.public_slug,
|
||
is_public = EXCLUDED.is_public,
|
||
display_name = EXCLUDED.display_name,
|
||
bio = EXCLUDED.bio,
|
||
updated_at = now()
|
||
""",
|
||
new
|
||
{
|
||
PlayerId = effectiveId.Value,
|
||
PublicSlug = string.IsNullOrWhiteSpace(publicSlug) ? null : publicSlug,
|
||
IsPublic = isPublic,
|
||
DisplayName = displayName,
|
||
Bio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim()
|
||
});
|
||
}
|
||
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UniqueViolation)
|
||
{
|
||
throw new InvalidOperationException("Master profile slug is already in use.", ex);
|
||
}
|
||
}
|
||
|
||
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
|
||
"""
|
||
SELECT mp.player_id AS PlayerId,
|
||
mp.public_slug AS Slug,
|
||
mp.display_name AS DisplayName,
|
||
mp.bio AS Bio
|
||
FROM master_profiles mp
|
||
WHERE mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
AND lower(mp.public_slug) = lower(@Slug)
|
||
""",
|
||
new { Slug = slug });
|
||
|
||
if (profile is null)
|
||
return null;
|
||
|
||
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
|
||
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
|
||
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
|
||
}
|
||
|
||
private static async Task<List<PublicMasterClub>> GetPublicClubsForMasterAsync(
|
||
NpgsqlConnection conn,
|
||
Guid playerId)
|
||
{
|
||
return (await conn.QueryAsync<PublicMasterClub>(
|
||
"""
|
||
SELECT DISTINCT g.id AS GroupId,
|
||
COALESCE(NULLIF(g.name, g.external_group_id), latest_session.title, g.name) AS Name,
|
||
g.public_slug AS Slug
|
||
FROM game_groups g
|
||
JOIN group_managers gm ON gm.group_id = g.id
|
||
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT s.title
|
||
FROM sessions s
|
||
WHERE s.group_id = g.id
|
||
ORDER BY s.scheduled_at DESC
|
||
LIMIT 1
|
||
) latest_session ON true
|
||
WHERE COALESCE(manager_link.primary_player_id, gm.player_id) = @PlayerId
|
||
AND g.public_schedule_enabled = true
|
||
AND g.public_slug IS NOT NULL
|
||
ORDER BY Name
|
||
""",
|
||
new { PlayerId = playerId })).ToList();
|
||
}
|
||
|
||
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
|
||
NpgsqlConnection conn,
|
||
Guid playerId,
|
||
Guid? viewerPlayerId)
|
||
{
|
||
return (await conn.QueryAsync<WebPublicSession>(
|
||
"""
|
||
SELECT DISTINCT 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.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.publication_mode AS PublicationMode,
|
||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||
mp.public_slug AS MasterProfileSlug,
|
||
mp.display_name AS MasterDisplayName
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
JOIN group_managers gm ON gm.group_id = g.id
|
||
LEFT JOIN player_links manager_link ON manager_link.secondary_player_id = gm.player_id
|
||
JOIN master_profiles mp ON mp.player_id = COALESCE(manager_link.primary_player_id, gm.player_id)
|
||
AND mp.player_id = @PlayerId
|
||
AND mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
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.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
AND (
|
||
s.publication_mode IN ('Catalog', 'Both')
|
||
OR (
|
||
s.publication_mode = 'ClubOnly'
|
||
AND @ViewerPlayerId IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1 FROM club_memberships cm
|
||
WHERE cm.group_id = s.group_id
|
||
AND cm.player_id = @ViewerPlayerId
|
||
AND cm.status = 'Active'
|
||
)
|
||
)
|
||
)
|
||
ORDER BY s.scheduled_at
|
||
""",
|
||
new
|
||
{
|
||
PlayerId = playerId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
ViewerPlayerId = viewerPlayerId
|
||
})).ToList();
|
||
}
|
||
|
||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||
NpgsqlConnection conn,
|
||
Guid groupId,
|
||
Guid? viewerPlayerId)
|
||
{
|
||
return (await conn.QueryAsync<WebPublicSession>(
|
||
"""
|
||
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.max_players AS MaxPlayers,
|
||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
|
||
s.publication_mode AS PublicationMode,
|
||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||
mp.public_slug AS MasterProfileSlug,
|
||
mp.display_name AS MasterDisplayName
|
||
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
|
||
LEFT JOIN LATERAL (
|
||
SELECT gm.player_id
|
||
FROM group_managers gm
|
||
WHERE gm.group_id = g.id
|
||
AND gm.role = @OwnerRole
|
||
ORDER BY gm.created_at
|
||
LIMIT 1
|
||
) owner_manager ON true
|
||
LEFT JOIN player_links owner_link ON owner_link.secondary_player_id = owner_manager.player_id
|
||
LEFT JOIN master_profiles mp ON mp.player_id = COALESCE(owner_link.primary_player_id, owner_manager.player_id)
|
||
AND mp.is_public = true
|
||
AND mp.public_slug IS NOT NULL
|
||
WHERE s.group_id = @GroupId
|
||
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
|
||
AND (
|
||
s.publication_mode IN ('Catalog', 'Both')
|
||
OR (
|
||
s.publication_mode = 'ClubOnly'
|
||
AND @ViewerPlayerId IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1 FROM club_memberships cm
|
||
WHERE cm.group_id = s.group_id
|
||
AND cm.player_id = @ViewerPlayerId
|
||
AND cm.status = 'Active'
|
||
)
|
||
)
|
||
)
|
||
ORDER BY s.scheduled_at
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||
ViewerPlayerId = viewerPlayerId
|
||
})).ToList();
|
||
}
|
||
|
||
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
|
||
Npgsql.NpgsqlConnection conn,
|
||
Guid batchId,
|
||
Guid groupId,
|
||
Npgsql.NpgsqlTransaction transaction)
|
||
{
|
||
return await conn.QuerySingleOrDefaultAsync<WebBatchInfo>(
|
||
"""
|
||
SELECT s.batch_id AS BatchId,
|
||
s.group_id AS GroupId,
|
||
(array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title,
|
||
(array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink,
|
||
g.external_group_id::BIGINT AS TelegramChatId,
|
||
(array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId,
|
||
(array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId,
|
||
(array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
WHERE s.batch_id = @BatchId
|
||
AND s.group_id = @GroupId
|
||
GROUP BY s.batch_id, s.group_id, g.external_group_id
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId },
|
||
transaction);
|
||
}
|
||
|
||
// --- Identity linking (issue #35) ---
|
||
|
||
public async Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||
}
|
||
|
||
public async Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
|
||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||
if (effectiveId is null)
|
||
return [];
|
||
|
||
return (await conn.QueryAsync<LinkedIdentity>(
|
||
"""
|
||
SELECT p.platform AS Platform,
|
||
p.external_user_id AS ExternalUserId,
|
||
p.display_name AS DisplayName,
|
||
p.external_username AS ExternalUsername,
|
||
p.avatar_url AS AvatarUrl,
|
||
COALESCE(pl.linked_at, p.created_at) AS LinkedAt
|
||
FROM players p
|
||
LEFT JOIN player_links pl ON pl.secondary_player_id = p.id
|
||
WHERE pl.primary_player_id = @EffectiveId
|
||
OR p.id = @EffectiveId
|
||
ORDER BY CASE WHEN p.id = @EffectiveId THEN 0 ELSE 1 END,
|
||
p.platform
|
||
""",
|
||
new { EffectiveId = effectiveId.Value })).ToList();
|
||
}
|
||
|
||
public async Task LinkIdentityAsync(
|
||
string currentPlatform, string currentExternalUserId,
|
||
string targetPlatform, string targetExternalUserId,
|
||
string? currentName)
|
||
{
|
||
if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId)
|
||
throw new InvalidOperationException("Cannot link an identity to itself.");
|
||
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
// Resolve current player (must exist — they are logged in)
|
||
var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
|
||
if (currentPlayerId is null)
|
||
throw new InvalidOperationException("Current player not found.");
|
||
|
||
// Upsert target player so it exists
|
||
var targetDisplayName = currentName ?? $"{targetPlatform} {targetExternalUserId}";
|
||
var targetPlayerId = await _UpsertPlayerAndGetIdAsync(conn, targetPlatform, targetExternalUserId, targetDisplayName, null, transaction);
|
||
|
||
// Check if target is already a primary of another link chain (conflict)
|
||
var targetIsPrimary = await conn.ExecuteScalarAsync<bool>(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM player_links WHERE primary_player_id = @TargetPlayerId
|
||
)
|
||
""",
|
||
new { TargetPlayerId = targetPlayerId }, transaction);
|
||
|
||
if (targetIsPrimary)
|
||
{
|
||
await _LogIdentityAuditAsync(conn, currentPlayerId.Value, "link_attempt_conflict",
|
||
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
|
||
await transaction.CommitAsync();
|
||
throw new InvalidOperationException("Target identity is already the primary account of another linked set.");
|
||
}
|
||
|
||
// Check if current is already a secondary (then their primary becomes the effective primary)
|
||
var currentPrimaryId = await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||
"""
|
||
SELECT primary_player_id
|
||
FROM player_links
|
||
WHERE secondary_player_id = @CurrentPlayerId
|
||
""",
|
||
new { CurrentPlayerId = currentPlayerId.Value }, transaction);
|
||
|
||
var effectiveCurrentPrimary = currentPrimaryId ?? currentPlayerId.Value;
|
||
|
||
// Check if target is already linked to someone else as secondary
|
||
var existingLink = await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||
"""
|
||
SELECT primary_player_id
|
||
FROM player_links
|
||
WHERE secondary_player_id = @TargetPlayerId
|
||
""",
|
||
new { TargetPlayerId = targetPlayerId }, transaction);
|
||
|
||
if (existingLink is not null && existingLink.Value != effectiveCurrentPrimary)
|
||
{
|
||
await _LogIdentityAuditAsync(conn, effectiveCurrentPrimary, "link_attempt_conflict",
|
||
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
|
||
await transaction.CommitAsync();
|
||
throw new InvalidOperationException("Target identity is already linked to another account.");
|
||
}
|
||
|
||
var effectivePrimary = currentPrimaryId ?? currentPlayerId.Value;
|
||
|
||
// Check if already linked
|
||
var alreadyLinked = await conn.ExecuteScalarAsync<bool>(
|
||
"""
|
||
SELECT EXISTS (
|
||
SELECT 1 FROM player_links
|
||
WHERE primary_player_id = @EffectivePrimary AND secondary_player_id = @TargetPlayerId
|
||
)
|
||
""",
|
||
new { EffectivePrimary = effectivePrimary, TargetPlayerId = targetPlayerId }, transaction);
|
||
|
||
if (alreadyLinked)
|
||
{
|
||
await transaction.CommitAsync();
|
||
return; // Already linked, idempotent
|
||
}
|
||
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO player_links (primary_player_id, secondary_player_id, linked_by_player_id)
|
||
VALUES (@PrimaryPlayerId, @SecondaryPlayerId, @LinkedByPlayerId)
|
||
""",
|
||
new
|
||
{
|
||
PrimaryPlayerId = effectivePrimary,
|
||
SecondaryPlayerId = targetPlayerId,
|
||
LinkedByPlayerId = currentPlayerId.Value
|
||
},
|
||
transaction);
|
||
|
||
await _LogIdentityAuditAsync(conn, effectivePrimary, "link",
|
||
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
}
|
||
|
||
public async Task UnlinkIdentityAsync(
|
||
string currentPlatform, string currentExternalUserId,
|
||
string targetPlatform, string targetExternalUserId)
|
||
{
|
||
if (currentPlatform == targetPlatform && currentExternalUserId == targetExternalUserId)
|
||
throw new InvalidOperationException("Cannot unlink your own primary identity from itself.");
|
||
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await using var transaction = await conn.BeginTransactionAsync();
|
||
|
||
var currentPlayerId = await _ResolvePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
|
||
if (currentPlayerId is null)
|
||
throw new InvalidOperationException("Current player not found.");
|
||
|
||
var targetPlayerId = await _ResolvePlayerIdAsync(conn, targetPlatform, targetExternalUserId);
|
||
if (targetPlayerId is null)
|
||
throw new InvalidOperationException("Target identity not found.");
|
||
|
||
var effectivePrimary = await _ResolveEffectivePlayerIdAsync(conn, currentPlatform, currentExternalUserId);
|
||
if (effectivePrimary is null)
|
||
throw new InvalidOperationException("Effective primary not found.");
|
||
|
||
// Only the primary account owner (or the linked identity itself) can unlink
|
||
var rows = await conn.ExecuteAsync(
|
||
"""
|
||
DELETE FROM player_links
|
||
WHERE primary_player_id = @EffectivePrimary
|
||
AND secondary_player_id = @TargetPlayerId
|
||
""",
|
||
new { EffectivePrimary = effectivePrimary.Value, TargetPlayerId = targetPlayerId.Value },
|
||
transaction);
|
||
|
||
if (rows == 0)
|
||
{
|
||
await transaction.RollbackAsync();
|
||
throw new InvalidOperationException("Identity is not linked to your account.");
|
||
}
|
||
|
||
await _LogIdentityAuditAsync(conn, effectivePrimary.Value, "unlink",
|
||
targetPlatform, targetExternalUserId, currentPlayerId.Value, transaction);
|
||
|
||
await transaction.CommitAsync();
|
||
}
|
||
|
||
public async Task UpsertPlayerAsync(string platform, string externalUserId, string displayName, string? avatarUrl)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, avatarUrl, null);
|
||
}
|
||
|
||
public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
await _UpsertPlayerAndGetIdAsync(conn, "Discord", discordId, displayName, avatarUrl, null);
|
||
}
|
||
|
||
// --- Private helpers ---
|
||
|
||
private static async Task<Guid?> _ResolvePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
|
||
{
|
||
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||
"""
|
||
SELECT id FROM players
|
||
WHERE platform = @Platform AND external_user_id = @ExternalUserId
|
||
""",
|
||
new { Platform = platform, ExternalUserId = externalUserId });
|
||
}
|
||
|
||
private static async Task<Guid?> _ResolveEffectivePlayerIdAsync(NpgsqlConnection conn, string platform, string externalUserId)
|
||
{
|
||
var playerId = await _ResolvePlayerIdAsync(conn, platform, externalUserId);
|
||
if (playerId is null)
|
||
return null;
|
||
|
||
var primaryId = await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||
"""
|
||
SELECT primary_player_id FROM player_links
|
||
WHERE secondary_player_id = @PlayerId
|
||
""",
|
||
new { PlayerId = playerId.Value });
|
||
|
||
return primaryId ?? playerId;
|
||
}
|
||
|
||
private static async Task<Guid[]> _ResolveLinkedPlayerIdsAsync(NpgsqlConnection conn, string platform, string externalUserId)
|
||
{
|
||
var effectiveId = await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||
if (effectiveId is null)
|
||
return [];
|
||
|
||
return (await conn.QueryAsync<Guid>(
|
||
"""
|
||
SELECT @EffectiveId
|
||
UNION
|
||
SELECT secondary_player_id
|
||
FROM player_links
|
||
WHERE primary_player_id = @EffectiveId
|
||
""",
|
||
new { EffectiveId = effectiveId.Value })).ToArray();
|
||
}
|
||
|
||
private static async Task<Guid> _UpsertPlayerAndGetIdAsync(
|
||
NpgsqlConnection conn, string platform, string externalUserId,
|
||
string displayName, string? avatarUrl, NpgsqlTransaction? transaction)
|
||
{
|
||
return await conn.QuerySingleAsync<Guid>(
|
||
"""
|
||
INSERT INTO players (display_name, platform, external_user_id, external_username, avatar_url)
|
||
VALUES (@DisplayName, @Platform, @ExternalUserId, @DisplayName, @AvatarUrl)
|
||
ON CONFLICT (platform, external_user_id)
|
||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||
DO UPDATE
|
||
SET display_name = EXCLUDED.display_name,
|
||
external_username = EXCLUDED.external_username,
|
||
avatar_url = COALESCE(EXCLUDED.avatar_url, players.avatar_url)
|
||
RETURNING id
|
||
""",
|
||
new { DisplayName = displayName, Platform = platform, ExternalUserId = externalUserId, AvatarUrl = avatarUrl },
|
||
transaction);
|
||
}
|
||
|
||
private static async Task _LogIdentityAuditAsync(
|
||
NpgsqlConnection conn, Guid playerId, string action,
|
||
string? targetPlatform, string? targetExternalUserId,
|
||
Guid? performedByPlayerId, NpgsqlTransaction? transaction)
|
||
{
|
||
await conn.ExecuteAsync(
|
||
"""
|
||
INSERT INTO identity_audit_log (player_id, action, target_platform, target_external_user_id, performed_by_player_id)
|
||
VALUES (@PlayerId, @Action, @TargetPlatform, @TargetExternalUserId, @PerformedByPlayerId)
|
||
""",
|
||
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
|
||
transaction);
|
||
}
|
||
|
||
// --- Private club showcases / memberships (issue #110) ---
|
||
|
||
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var count = await conn.ExecuteScalarAsync<long>(
|
||
"""
|
||
SELECT COUNT(*) FROM club_memberships
|
||
WHERE group_id = @GroupId
|
||
AND player_id = @PlayerId
|
||
AND status = 'Active'
|
||
""",
|
||
new { GroupId = groupId, PlayerId = playerId });
|
||
return count > 0;
|
||
}
|
||
|
||
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
|
||
}
|
||
|
||
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
|
||
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebClubShowcaseSession>(
|
||
"""
|
||
SELECT s.id AS Id,
|
||
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.publication_mode AS PublicationMode,
|
||
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
|
||
s.description AS Description,
|
||
s.allow_direct_registration AS AllowDirectRegistration
|
||
FROM sessions s
|
||
JOIN game_groups g ON g.id = s.group_id
|
||
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.group_id = @GroupId
|
||
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
|
||
AND (
|
||
s.publication_mode IN ('Catalog', 'Both')
|
||
OR (
|
||
s.publication_mode = 'ClubOnly'
|
||
AND @ViewerPlayerId IS NOT NULL
|
||
AND EXISTS (
|
||
SELECT 1 FROM club_memberships cm
|
||
WHERE cm.group_id = s.group_id
|
||
AND cm.player_id = @ViewerPlayerId
|
||
AND cm.status = 'Active'
|
||
)
|
||
)
|
||
)
|
||
ORDER BY s.scheduled_at ASC
|
||
OFFSET @Offset LIMIT @PageSize
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
ViewerPlayerId = viewerPlayerId,
|
||
Offset = page * pageSize,
|
||
PageSize = pageSize
|
||
})).ToList();
|
||
}
|
||
|
||
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)::int FROM club_memberships
|
||
WHERE group_id = @GroupId AND status = 'Pending'
|
||
""",
|
||
new { GroupId = groupId });
|
||
}
|
||
|
||
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebPendingApplication>(
|
||
"""
|
||
SELECT cm.id AS MembershipId,
|
||
p.id AS PlayerId,
|
||
p.display_name AS DisplayName,
|
||
p.platform AS Platform,
|
||
p.external_username AS ExternalUsername,
|
||
cm.message AS Message,
|
||
cm.applied_at AS AppliedAt
|
||
FROM club_memberships cm
|
||
JOIN players p ON p.id = cm.player_id
|
||
WHERE cm.group_id = @GroupId
|
||
AND cm.status = 'Pending'
|
||
ORDER BY cm.applied_at ASC
|
||
""",
|
||
new { GroupId = groupId })).ToList();
|
||
}
|
||
|
||
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return (await conn.QueryAsync<WebMembership>(
|
||
"""
|
||
SELECT cm.id AS MembershipId,
|
||
cm.group_id AS GroupId,
|
||
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
|
||
g.public_slug AS GroupSlug,
|
||
cm.status AS Status,
|
||
cm.role AS Role,
|
||
cm.message AS Message,
|
||
cm.applied_at AS AppliedAt,
|
||
cm.decided_at AS DecidedAt,
|
||
decider.display_name AS DecidedByDisplayName
|
||
FROM club_memberships cm
|
||
JOIN game_groups g ON g.id = cm.group_id
|
||
LEFT JOIN players decider ON decider.id = cm.decided_by
|
||
WHERE cm.player_id = @PlayerId
|
||
ORDER BY cm.applied_at DESC
|
||
""",
|
||
new { PlayerId = playerId })).ToList();
|
||
}
|
||
|
||
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var existing = await conn.ExecuteScalarAsync<int>(
|
||
"""
|
||
SELECT COUNT(*)::int FROM club_memberships
|
||
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
|
||
""",
|
||
new { GroupId = groupId, PlayerId = playerId });
|
||
if (existing > 0)
|
||
{
|
||
throw new InvalidOperationException("Active or pending application already exists for this player.");
|
||
}
|
||
|
||
return await conn.ExecuteScalarAsync<Guid>(
|
||
"""
|
||
INSERT INTO club_memberships (group_id, player_id, status, message)
|
||
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
|
||
RETURNING id
|
||
""",
|
||
new { GroupId = groupId, PlayerId = playerId, Message = message });
|
||
}
|
||
|
||
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var rows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE club_memberships
|
||
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
|
||
WHERE id = @MembershipId AND status = 'Pending'
|
||
""",
|
||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||
if (rows == 0)
|
||
{
|
||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||
}
|
||
}
|
||
|
||
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var rows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE club_memberships
|
||
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
|
||
WHERE id = @MembershipId AND status = 'Pending'
|
||
""",
|
||
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
|
||
if (rows == 0)
|
||
{
|
||
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
|
||
}
|
||
}
|
||
|
||
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
// Active membership: withdraw by setting status = 'Left'.
|
||
var rows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE club_memberships
|
||
SET status = 'Left', decided_at = now()
|
||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
|
||
""",
|
||
new { MembershipId = membershipId, PlayerId = playerId });
|
||
if (rows > 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
|
||
var cancelled = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE club_memberships
|
||
SET status = 'Rejected', decided_at = now()
|
||
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
|
||
""",
|
||
new { MembershipId = membershipId, PlayerId = playerId });
|
||
if (cancelled == 0)
|
||
{
|
||
throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
|
||
}
|
||
}
|
||
|
||
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
return await conn.QuerySingleOrDefaultAsync<Guid?>(
|
||
"""
|
||
SELECT group_id FROM club_memberships WHERE id = @MembershipId
|
||
""",
|
||
new { MembershipId = membershipId });
|
||
}
|
||
}
|