feat(web): add public master profiles
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
This commit is contained in:
2026-05-29 00:08:14 +03:00
parent d81564c308
commit 0c1d3abd7e
21 changed files with 980 additions and 39 deletions
@@ -90,6 +90,52 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
}
public Task<MasterProfileSettings?> GetMasterProfileSettingsForCurrentUserAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return Task.FromResult<MasterProfileSettings?>(null);
return sessionStore.GetMasterProfileSettingsAsync(identity.Value.Platform, identity.Value.ExternalUserId);
}
public async Task UpdateMasterProfileSettingsForCurrentUserAsync(
string? publicSlug,
bool isPublic,
string displayName,
string? bio)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var normalizedDisplayName = displayName.Trim();
if (normalizedDisplayName.Length is < 2 or > 120)
{
throw new InvalidOperationException("Имя профиля должно быть от 2 до 120 символов.");
}
var normalizedBio = string.IsNullOrWhiteSpace(bio) ? null : bio.Trim();
if (normalizedBio?.Length > 1200)
{
throw new InvalidOperationException("Описание профиля должно быть не длиннее 1200 символов.");
}
var normalizedSlug = NormalizeMasterProfileSlug(publicSlug);
if (isPublic && normalizedSlug is null)
{
throw new InvalidOperationException("Для публичного профиля нужен короткий адрес.");
}
await sessionStore.UpdateMasterProfileSettingsAsync(
identity.Value.Platform,
identity.Value.ExternalUserId,
normalizedSlug,
isPublic,
normalizedDisplayName,
normalizedBio);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
{
var identity = GetCurrentIdentity();
@@ -472,4 +518,6 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
return slug;
}
private static string? NormalizeMasterProfileSlug(string? publicSlug) => NormalizePublicSlug(publicSlug);
}
+27 -1
View File
@@ -42,12 +42,35 @@ public sealed record WebPublicSession(
string Status,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount);
int WaitlistedPlayerCount,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
string Slug,
IReadOnlyList<WebPublicSession> Sessions,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record MasterProfileSettings(
Guid PlayerId,
string DisplayName,
string? PublicSlug,
bool IsPublic,
string? Bio);
public sealed record PublicMasterClub(
Guid GroupId,
string Name,
string Slug);
public sealed record PublicMasterProfile(
string Slug,
string DisplayName,
string? Bio,
IReadOnlyList<PublicMasterClub> Clubs,
IReadOnlyList<WebPublicSession> Sessions);
public interface ISessionStore
@@ -85,6 +108,9 @@ public interface ISessionStore
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId);
Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
+273 -15
View File
@@ -110,7 +110,12 @@ internal sealed record WebBatchSessionRow(
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);
internal sealed record WebPublicGroupRow(
Guid GroupId,
string Name,
string Slug,
string? MasterProfileSlug,
string? MasterDisplayName);
internal sealed record ShowcaseSessionRow(
Guid Id,
Guid GroupId,
@@ -128,7 +133,10 @@ internal sealed record ShowcaseSessionRow(
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description);
string? Description,
string? MasterProfileSlug,
string? MasterDisplayName);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -303,7 +311,9 @@ public sealed class SessionService(
"""
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
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
@@ -312,11 +322,23 @@ public sealed class SessionService(
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 });
new { Slug = slug, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
if (group is null)
{
@@ -324,7 +346,7 @@ public sealed class SessionService(
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
}
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
@@ -341,7 +363,9 @@ public sealed class SessionService(
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
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 (
@@ -365,6 +389,18 @@ public sealed class SessionService(
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
@@ -377,7 +413,8 @@ public sealed class SessionService(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
}
@@ -402,7 +439,9 @@ public sealed class SessionService(
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
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 (
@@ -426,6 +465,18 @@ public sealed class SessionService(
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
@@ -459,14 +510,15 @@ public sealed class SessionService(
filter.IsOneShot,
filter.Format,
PageSize = pageSize,
Offset = (page - 1) * 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)).ToList();
r.Description, r.MasterProfileSlug, r.MasterDisplayName)).ToList();
}
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
@@ -490,7 +542,9 @@ public sealed class SessionService(
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
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 (
@@ -514,6 +568,18 @@ public sealed class SessionService(
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
@@ -526,7 +592,8 @@ public sealed class SessionService(
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
});
if (row is null)
@@ -536,7 +603,7 @@ public sealed class SessionService(
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.Description, row.MasterProfileSlug, row.MasterDisplayName);
}
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
@@ -1822,6 +1889,182 @@ public sealed class SessionService(
}
}
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)
@@ -1837,7 +2080,9 @@ public sealed class SessionService(
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
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 (
@@ -1861,6 +2106,18 @@ public sealed class SessionService(
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
@@ -1874,7 +2131,8 @@ public sealed class SessionService(
GroupId = groupId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
})).ToList();
}