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:
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.RegularExpressions;
|
||||
using GmRelay.Shared.Domain;
|
||||
|
||||
namespace GmRelay.Web.Services;
|
||||
@@ -54,6 +55,71 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task<WebPublicGroupSettings?> GetPublicGroupSettingsForCurrentUserAsync(Guid groupId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
return null;
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
return null;
|
||||
|
||||
return await sessionStore.GetPublicGroupSettingsAsync(groupId);
|
||||
}
|
||||
|
||||
public async Task UpdatePublicGroupSettingsForCurrentUserAsync(
|
||||
Guid groupId,
|
||||
string? publicSlug,
|
||||
bool publicScheduleEnabled)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||
{
|
||||
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
var normalizedSlug = NormalizePublicSlug(publicSlug);
|
||||
if (publicScheduleEnabled && normalizedSlug is null)
|
||||
{
|
||||
throw new InvalidOperationException("Для публичной страницы нужен короткий адрес.");
|
||||
}
|
||||
|
||||
await sessionStore.UpdatePublicGroupSettingsAsync(groupId, normalizedSlug, publicScheduleEnabled);
|
||||
}
|
||||
|
||||
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
if (identity is null)
|
||||
throw new InvalidOperationException("User is not authenticated.");
|
||||
|
||||
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||
if (batch is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||
}
|
||||
|
||||
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||
{
|
||||
var identity = GetCurrentIdentity();
|
||||
@@ -390,4 +456,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
|
||||
JoinLink = joinLink
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizePublicSlug(string? publicSlug)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(publicSlug))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var slug = Regex.Replace(publicSlug.Trim().ToLowerInvariant(), @"[\s_]+", "-").Trim('-');
|
||||
if (slug.Length is < 3 or > 80 || !Regex.IsMatch(slug, "^[a-z0-9]+(?:-[a-z0-9]+)*$"))
|
||||
{
|
||||
throw new InvalidOperationException("Короткий адрес может содержать только латинские буквы, цифры и дефисы.");
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,10 +24,41 @@ public sealed record SessionAuditLogEntry(
|
||||
string? NewValue,
|
||||
DateTime ChangedAt);
|
||||
|
||||
public sealed record WebPublicGroupSettings(
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? PublicSlug,
|
||||
bool PublicScheduleEnabled,
|
||||
int PublicSessionCount);
|
||||
|
||||
public sealed record WebPublicSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string GroupName,
|
||||
string? GroupSlug,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
|
||||
public sealed record WebPublicClub(
|
||||
Guid GroupId,
|
||||
string Name,
|
||||
string Slug,
|
||||
IReadOnlyList<WebPublicSession> Sessions);
|
||||
|
||||
public interface ISessionStore
|
||||
{
|
||||
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId);
|
||||
Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled);
|
||||
Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic);
|
||||
Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic);
|
||||
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug);
|
||||
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId);
|
||||
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user