0c1d3abd7e
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.
Bump version -> 3.5.0
2436 lines
100 KiB
C#
2436 lines
100 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,
|
||
bool IsPublic = false);
|
||
|
||
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);
|
||
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.is_public = true
|
||
) 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 SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET is_public = @IsPublic,
|
||
updated_at = now()
|
||
WHERE id = @SessionId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(sessionId, "0");
|
||
}
|
||
}
|
||
|
||
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
|
||
{
|
||
await using var conn = await dataSource.OpenConnectionAsync();
|
||
var updatedRows = await conn.ExecuteAsync(
|
||
"""
|
||
UPDATE sessions
|
||
SET is_public = @IsPublic,
|
||
updated_at = now()
|
||
WHERE batch_id = @BatchId
|
||
AND group_id = @GroupId
|
||
""",
|
||
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
|
||
|
||
if (updatedRows == 0)
|
||
{
|
||
throw new SessionAccessDeniedException(batchId, "0");
|
||
}
|
||
}
|
||
|
||
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
|
||
{
|
||
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.added_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);
|
||
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
|
||
}
|
||
|
||
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
|
||
{
|
||
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,
|
||
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.added_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.is_public = true
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
""",
|
||
new
|
||
{
|
||
SessionId = sessionId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||
});
|
||
}
|
||
|
||
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
|
||
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.added_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.is_public = true
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
AND (
|
||
@DateFilter = 'All'
|
||
OR (@DateFilter = 'Today' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '1 day')
|
||
OR (@DateFilter = 'Tomorrow' AND s.scheduled_at >= CURRENT_DATE + interval '1 day' AND s.scheduled_at < CURRENT_DATE + interval '2 days')
|
||
OR (@DateFilter = 'ThisWeek' AND s.scheduled_at >= CURRENT_DATE AND s.scheduled_at < CURRENT_DATE + interval '7 days')
|
||
)
|
||
AND (
|
||
@SeatFilter = 'Any'
|
||
OR (@SeatFilter = 'Available' AND (s.max_players IS NULL OR active_counts.count < s.max_players))
|
||
OR (@SeatFilter = 'Waitlist' AND (s.max_players IS NOT NULL AND active_counts.count >= s.max_players))
|
||
)
|
||
AND (@System IS NULL OR s.system = @System)
|
||
AND (@IsOneShot IS NULL OR s.is_one_shot = @IsOneShot)
|
||
AND (@Format IS NULL OR s.format = @Format)
|
||
ORDER BY s.scheduled_at ASC
|
||
LIMIT @PageSize OFFSET @Offset
|
||
""",
|
||
new
|
||
{
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
DateFilter = filter.Date.ToString(),
|
||
SeatFilter = filter.Seats.ToString(),
|
||
filter.System,
|
||
filter.IsOneShot,
|
||
filter.Format,
|
||
PageSize = pageSize,
|
||
Offset = (page - 1) * pageSize,
|
||
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, 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
|
||
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.added_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.is_public = true
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
""",
|
||
new
|
||
{
|
||
SessionId = sessionId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
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, 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.is_public = true
|
||
AND g.public_schedule_enabled = true
|
||
AND g.public_slug IS NOT NULL
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
FOR UPDATE OF s
|
||
""",
|
||
new { SessionId = sessionId, Cancelled = SessionStatus.Cancelled },
|
||
transaction);
|
||
|
||
if (session is null || !(bool)session.allowdirectregistration)
|
||
{
|
||
await transaction.RollbackAsync();
|
||
return false;
|
||
}
|
||
|
||
var playerId = await _UpsertPlayerAndGetIdAsync(conn, platform, externalUserId, displayName, null, transaction);
|
||
|
||
var registrationStatus = SessionCapacityRules.DecideJoinStatus(
|
||
(int?)session.maxplayers,
|
||
await conn.ExecuteScalarAsync<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.is_public AS IsPublic
|
||
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.is_public AS IsPublic
|
||
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.is_public AS IsPublic
|
||
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.is_public AS IsPublic
|
||
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.is_public AS IsPublic
|
||
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)
|
||
{
|
||
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);
|
||
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)
|
||
{
|
||
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,
|
||
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.is_public = true
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
ORDER BY s.scheduled_at
|
||
""",
|
||
new
|
||
{
|
||
PlayerId = playerId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled
|
||
})).ToList();
|
||
}
|
||
|
||
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
|
||
NpgsqlConnection conn,
|
||
Guid groupId)
|
||
{
|
||
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,
|
||
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.added_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.is_public = true
|
||
AND s.scheduled_at > now() - interval '4 hours'
|
||
AND s.status <> @Cancelled
|
||
ORDER BY s.scheduled_at
|
||
""",
|
||
new
|
||
{
|
||
GroupId = groupId,
|
||
Active = ParticipantRegistrationStatus.Active,
|
||
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
|
||
Cancelled = SessionStatus.Cancelled,
|
||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||
})).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);
|
||
}
|
||
}
|