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
@@ -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;
}
}