feat: add public club pages
PR Checks / test-and-build (pull_request) Successful in 12m47s

Add publication settings for clubs and sessions, read-only public club/session pages, dashboard controls, privacy-focused public queries, docs, and tests.

Bump version to 3.3.0
This commit is contained in:
2026-05-28 12:23:47 +03:00
parent fac5d75c7e
commit 3418d1a46c
18 changed files with 1239 additions and 24 deletions
+247 -6
View File
@@ -67,7 +67,8 @@ public sealed record WebSession(
int ActivePlayerCount,
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null);
int? ThreadId = null,
bool IsPublic = false);
public sealed record WebParticipant(
Guid Id,
@@ -108,6 +109,7 @@ 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);
public sealed class SessionService(
NpgsqlDataSource dataSource,
@@ -182,6 +184,184 @@ public sealed class SessionService(
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
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.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND lower(g.public_slug) = lower(@Slug)
""",
new { Slug = slug });
if (group is null)
{
return null;
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions);
}
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
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
SELECT recent.title
FROM sessions recent
WHERE recent.group_id = g.id
ORDER BY recent.scheduled_at DESC
LIMIT 1
) latest_session ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Active
) active_counts ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
""",
new
{
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
});
}
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
@@ -379,7 +559,8 @@ public sealed class SessionService(
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.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 (
@@ -417,7 +598,8 @@ public sealed class SessionService(
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.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 (
@@ -476,7 +658,8 @@ public sealed class SessionService(
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
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",
@@ -562,7 +745,8 @@ public sealed class SessionService(
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
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
@@ -688,7 +872,8 @@ public sealed class SessionService(
0 AS ActivePlayerCount,
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId
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
@@ -1396,6 +1581,62 @@ public sealed class SessionService(
}
}
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
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
SELECT recent.title
FROM sessions recent
WHERE recent.group_id = g.id
ORDER BY recent.scheduled_at DESC
LIMIT 1
) latest_session ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Active
) active_counts ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS count
FROM session_participants sp
WHERE sp.session_id = s.id
AND sp.is_gm = false
AND sp.registration_status = @Waitlisted
) waitlist_counts ON true
WHERE s.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
})).ToList();
}
private static async Task<WebBatchInfo?> GetBatchInfoAsync(
Npgsql.NpgsqlConnection conn,
Guid batchId,