Compare commits

..

24 Commits

Author SHA1 Message Date
Toutsu a843c8b278 style: dotnet format pass on wizard code
PR Checks / test-and-build (pull_request) Successful in 8m12s
Deploy Telegram Bot / build-and-push (push) Successful in 5m37s
Deploy Telegram Bot / scan-images (push) Successful in 1m34s
Deploy Telegram Bot / deploy (push) Successful in 42s
2026-06-04 15:33:25 +03:00
Toutsu 186492a18d test(wizard): add submit, cleanup, router delegation tests 2026-06-04 10:33:50 +03:00
Toutsu 2819786f91 test(wizard): add wizard tests + refactor to IWizardDraftRepository
- Extract IWizardDraftRepository interface for testability (NSubstitute cannot
  mock sealed classes; the codebase uses fake-style doubles instead).
- Add step-transition, pool-slot, validation, cancel/back, and render-shape tests
  using FakeWizardDraftRepository and FakeWizardMessenger.
- Fix wizard payload persistence bug: HandleCallbackAsync and HandleTextAsync
  now call SavePayload after ApplyChoice/ApplyText mutations, so subsequent
  LoadPayload calls see the user's progress. Previously, local WizardPayload
  mutations were discarded and the wizard reset on every step.
- CommitCurrentPoolSlot now auto-creates a slot via EnsureCurrentPoolSlot when
  one is missing, so the PoolSlotCapacity → waitlist click is recoverable
  even if the user lands on the step without a slot.
2026-06-04 09:53:15 +03:00
Toutsu 8c1bda73ed feat(wizard): register wizard services in Program.cs DI 2026-06-04 09:18:16 +03:00
Toutsu af345ba765 feat(wizard): delegate updates to wizard when an active draft exists 2026-06-04 09:14:13 +03:00
Toutsu 4a04d7d723 refactor(wizard): make CreateSessionHandler wizard-driven and remove legacy parser 2026-06-04 09:00:37 +03:00
Toutsu eeffae659f feat(wizard): add WizardDraftCleanupService (1-min tick) 2026-06-04 08:44:57 +03:00
Toutsu ea567a36ee feat(wizard): add GameCreationWizard state-machine service 2026-06-04 08:42:43 +03:00
Toutsu be86a2a08a feat(wizard): add WizardStep renderer (single + pool steps) 2026-06-04 08:33:53 +03:00
Toutsu 1b49211085 feat(wizard): add WizardStorageException 2026-06-04 08:30:50 +03:00
Toutsu 96a4807002 feat(wizard): add ITelegramWizardMessenger (edit/send/answer/club-list) 2026-06-04 08:28:30 +03:00
Toutsu cff4e48b57 feat(wizard): add step name and callback data constants 2026-06-04 08:18:15 +03:00
Toutsu 384887a862 test(wizard): add WizardDraftRepository integration tests 2026-06-04 08:13:22 +03:00
Toutsu 4d2aef637f fix(wizard): bind @PayloadJson parameter in UpsertAsync INSERT
The UpsertAsync SQL used @Payload (without 'Json' suffix) but the
WizardDraft POCO exposes the property as PayloadJson. Dapper.AOT
requires parameter names to match property names, so the parameter
went through unbinded and PostgreSQL rejected 'payload' as a column
reference. Without integration tests this went unnoticed; the new
WizardDraftRepositoryTests now exercise the path and surface it.
2026-06-04 08:13:10 +03:00
Toutsu c45c46abcf feat(wizard): add WizardDraftRepository (Dapper.AOT) 2026-06-04 08:01:46 +03:00
Toutsu 2c7495cd8d feat(wizard): add WizardPayload with AOT JSON source-gen 2026-06-04 07:59:13 +03:00
Toutsu d5fdc19016 feat(wizard): add WizardDraft POCO 2026-06-04 07:56:38 +03:00
Toutsu 10410d758c feat(db): add wizard_drafts table (V031) 2026-06-04 07:54:43 +03:00
Toutsu 771ff9be34 Merge pull request #120: fix(web): include PublicationMode/IsMembersOnly in showcase SQL (v3.7.1)
Deploy Telegram Bot / build-and-push (push) Successful in 5m11s
Deploy Telegram Bot / scan-images (push) Successful in 1m30s
Deploy Telegram Bot / deploy (push) Successful in 38s
2026-06-03 22:31:17 +03:00
Toutsu 29f6f6a827 fix(web): include PublicationMode/IsMembersOnly in showcase SQL to fix /showcase 500
PR Checks / test-and-build (pull_request) Successful in 8m17s
Dapper.AOT generated a 19-parameter ctor for ShowcaseSessionRow based on the
SELECT list in GetShowcaseSessionsAsync / GetShowcaseSessionAsync. After
adding PublicationMode and IsMembersOnly to ShowcaseSessionDto in v3.7.0 the
record itself was extended, but the SELECT still returned 19 columns, so the
materializer threw "A parameterless default constructor or one matching
signature (...) is required" and every request to /showcase returned 500.

Add s.publication_mode and (s.publication_mode = 'ClubOnly') to both SELECT
lists and propagate them through the ShowcaseSessionDto construction. The
field list now matches the generated constructor exactly.

Version bump 3.7.0 -> 3.7.1 (patch).
2026-06-03 22:21:31 +03:00
Toutsu 6951c72f3c Merge pull request #119: feat(web): private club showcases with membership flow (v3.7.0, issue #110)
Deploy Telegram Bot / build-and-push (push) Successful in 5m29s
Deploy Telegram Bot / scan-images (push) Successful in 1m29s
Deploy Telegram Bot / deploy (push) Successful in 39s
2026-06-03 11:46:01 +03:00
Toutsu 22e9859fdf fix(web): allow cancelling pending applications; drop contradictory message guard
PR Checks / test-and-build (pull_request) Successful in 7m50s
Address review feedback from PR #119:

- LeaveClubMembershipAsync: was rejecting Pending rows because the SQL
  required status = 'Active', so clicking "Отозвать заявку" on a Pending
  membership surfaced a misleading "Active membership X not found"
  InvalidOperationException. Now the method first tries Active -> Left
  and falls back to Pending -> Rejected so the same UI flow covers both
  states.
- PublicClub.razor TrySubmitApplicationAsync: removed the empty-input
  guard that contradicted the "(необязательно)" label and the server
  side (AuthorizedMembershipService already trims and accepts null).

No tests broken (493 still passing), no public-API changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:33:28 +03:00
Toutsu 6cb2fbe610 feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
Implements Issue #110: game masters can now publish sessions
exclusively to a club's private showcase, gated behind a
member application and approval flow. Adds a 4-state
publication_mode (None/Catalog/ClubOnly/Both) replacing the
binary is_public, plus a club_memberships table with
Pending/Active/Rejected/Left lifecycle and partial unique
index ensuring a single Active row per (group, player).

Highlights
- V030 migration: club_memberships, publication_mode, drop
  is_public, recreate partial indexes, portfolio_games gains
  publication_mode.
- PublicationMode enum + extensions in GmRelay.Shared.
- ISessionStore gains 12 membership/showcase methods;
  AuthorizedMembershipService owns the membership flow with
  GM-only approve/reject authorization.
- PublicClub / PublicMasterProfile / PublicSession: member-
  aware queries (ClubOnly visible only to Active members).
- New pages: MyClubMemberships (/profile/memberships) and
  ClubApplications (/group/{id}/applications).
- GroupDetails and EditSession switch from a bool toggle to
  a 4-state publication_mode selector.
- NavMenu adds Moji kluby, PublicLayout adds Kluby.

Tests: 4 new test files (PublicationMode, ClubMemberships,
AuthorizedMembershipService, ClubShowcaseSource) + updates
to PublicClubPages, AuthorizedSessionService/Portfolio
service FakeSessionStore, CampaignTemplatesNavigation.
493 tests pass.

Bump version 3.6.0 -> 3.7.0 across Directory.Build.props,
compose.yaml, deploy.yml, NavMenu.razor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 11:09:22 +03:00
Toutsu 992f71c0e4 Merge pull request 'feat(web): add completed-game portfolio to GM showcase (issue #108)' (#118) from codex/feature-issue-108-portfolio into main
Deploy Telegram Bot / build-and-push (push) Successful in 5m36s
Deploy Telegram Bot / scan-images (push) Successful in 1m44s
Deploy Telegram Bot / deploy (push) Successful in 39s
Reviewed-on: #118
2026-06-02 18:28:48 +03:00
65 changed files with 5041 additions and 655 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.6.0
VERSION: 3.7.1
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.6.0</Version>
<Version>3.7.1</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.6.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.1
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.6.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.1
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.6.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.1
restart: always
depends_on:
db:
@@ -1,194 +1,254 @@
using Dapper;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using GmRelay.Bot.Infrastructure.Telegram;
using Npgsql;
using Telegram.Bot;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
public sealed class CreateSessionHandler(
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler sharedHandler,
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
ILogger<CreateSessionHandler> logger)
/// <summary>
/// Wizard-driven entry point for game-session creation. Replaces the legacy
/// text-template parser. Exposes <see cref="StartWizardAsync"/> (called from
/// <c>/newsession</c>), <see cref="TryResumeAsync"/> (continue a draft), and
/// <see cref="SubmitDraftAsync"/> (finalize on "✅ Создать" callback).
/// </summary>
public sealed class CreateSessionHandler
{
public async Task HandleAsync(Message message, CancellationToken ct)
private const int MaxRetries = 3;
private readonly IWizardDraftRepository _drafts;
private readonly SharedCreateSessionHandler _shared;
private readonly ITelegramWizardMessenger _messenger;
private readonly ILogger<CreateSessionHandler> _log;
public CreateSessionHandler(
IWizardDraftRepository drafts,
SharedCreateSessionHandler shared,
ITelegramWizardMessenger messenger,
ILogger<CreateSessionHandler> log)
{
var parseResult = NewSessionCommandParser.Parse(message.Text ?? message.Caption, DateTimeOffset.UtcNow);
_drafts = drafts;
_shared = shared;
_messenger = messenger;
_log = log;
}
foreach (var timeInput in parseResult.PastTimeInputs)
/// <summary>
/// Entry point for <c>/newsession</c>. If a non-expired draft already exists for
/// this (chat, thread, owner), returns <c>null</c> so the caller can render a
/// "Continue / Start over / Cancel" menu.
/// </summary>
public async Task<WizardDraft?> StartWizardAsync(Message message, CancellationToken ct)
{
var existing = await _drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
if (existing is not null)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
ct);
return null;
}
foreach (var timeInput in parseResult.InvalidTimeInputs)
var draft = new WizardDraft
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
ct);
}
Id = Guid.NewGuid(),
ChatId = message.Chat.Id,
MessageThreadId = message.MessageThreadId,
OwnerTelegramId = message.From?.Id ?? 0,
Step = WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
foreach (var seatLimitInput in parseResult.InvalidSeatLimitInputs)
var (text, kb) = WizardStep.Render(draft, new WizardPayload());
var msgId = await _messenger.SendGroupMessageAsync(
draft.ChatId, draft.MessageThreadId, text, kb, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
/// <summary>
/// Resume an existing draft — returns the draft row so the caller can re-render.
/// </summary>
public Task<WizardDraft?> TryResumeAsync(Message message, CancellationToken ct) =>
_drafts.GetActiveAsync(
message.Chat.Id, message.MessageThreadId, message.From?.Id ?? 0, ct);
/// <summary>
/// Finalize: build shared command(s), call the shared handler, edit the wizard message.
/// On failure, retry up to <see cref="MaxRetries"/> times before deleting the draft.
/// </summary>
public async Task SubmitDraftAsync(WizardDraft draft, CancellationToken ct)
{
var payload = LoadPayload(draft);
if (!IsComplete(payload, out var missing))
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
ct);
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
ct);
}
if (!parseResult.IsValid)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"""
Не удалось распознать формат. Пожалуйста, используйте шаблон:
/newsession
Название: My Game
Время: 15.05.2026 19:30
Время: 22.05.2026 19:30
Мест: 4
Ссылка: https://link
Картинка: https://cover
Для повтора можно указать одну дату и строки:
Игр: 4
Интервал: 7
""",
ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct);
return;
}
var imageReference = GetBatchImageReference(message, parseResult.ImageUrl);
var gmId = message.From!.Id;
var gmName = message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? string.Empty : $" {message.From.LastName}");
var gmUsername = message.From.Username;
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
var messageThreadId = topicDestination.MessageThreadId;
if (topicDestination.ShouldCreateForumTopic)
{
try
{
var topicRef = await messenger.CreateThreadAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
$"🎲 Игры: {parseResult.Title}",
ct);
messageThreadId = int.Parse(topicRef.ExternalThreadId!, System.Globalization.CultureInfo.InvariantCulture);
}
catch (Exception ex)
when (ex.Message.Contains("not enough rights") ||
ex.Message.Contains("CHAT_ADMIN_REQUIRED") ||
ex.Message.Contains("not an administrator"))
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
TelegramTopicRouting.MissingForumTopicRightsMessage,
ct);
return;
}
}
var platformGroup = TelegramPlatformIds.Group(message.Chat.Id, messageThreadId, message.Chat.Title ?? "Private Chat");
var platformUser = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
gmName,
gmUsername);
var command = new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionCommand(
platformUser,
platformGroup,
parseResult.Title!,
parseResult.Link!,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
imageReference);
GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionResult result;
var commands = BuildCommands(draft, payload);
try
{
result = await sharedHandler.HandleAsync(command, ct);
}
catch
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
"💥 Произошла ошибка базы данных при создании сессии.",
ct);
throw;
}
if (!result.Success)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, null),
result.ErrorMessage!,
ct);
return;
}
var scheduleMessage = new PlatformScheduleMessage(
platformGroup,
result.View!,
null,
imageReference);
var sentMessageRef = await messenger.SendScheduleAsync(scheduleMessage, ct);
// Store batch_message_id
if (int.TryParse(sentMessageRef.ExternalMessageId, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var batchMessageId))
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessageId, BatchId = result.BatchId });
}
// Delete original message
try
{
await messenger.DeleteMessageAsync(
TelegramPlatformIds.Message(message.Chat.Id, null, message.MessageId),
ct);
foreach (var cmd in commands)
{
await _shared.HandleAsync(cmd, ct);
}
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
EmptyKeyboard(), ct);
await _drafts.DeleteAsync(draft.Id, ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, message.Chat.Id);
_log.LogError(ex, "SubmitDraftAsync failed for draft {DraftId}", draft.Id);
payload.RetryCount += 1;
SavePayload(draft, payload);
if (payload.RetryCount >= MaxRetries)
{
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
"💥 Не удалось создать сессию после 3 попыток. Используйте /newsession, чтобы начать заново.",
EmptyKeyboard(), ct);
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
RetryCancelKeyboard(), ct);
}
}
internal static string? GetBatchImageReference(Message message, string? parsedImageUrl)
// ── Build shared commands ────────────────────────────────────────
// The shared handler creates one session per scheduled time in a single transaction
// and assigns the same batch_id to all of them. A wizard pool therefore produces ONE
// command with N times; a single-game wizard produces ONE command with one time.
private static List<CreateSessionCommand> BuildCommands(WizardDraft draft, WizardPayload p)
{
var attachedPhotoFileId = message.Photo?
.OrderByDescending(photo => photo.FileSize ?? 0)
.ThenByDescending(photo => photo.Width * photo.Height)
.FirstOrDefault()
?.FileId;
if (!string.IsNullOrWhiteSpace(attachedPhotoFileId))
if (p.Type == WizardCreationType.Pool && p.Pool is { } pool && pool.Slots.Count > 0)
{
return attachedPhotoFileId;
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
pool.Slots.Select(s => s.ScheduledAt).ToList(),
MaxPlayersForPool(pool),
isOneShot: false)
};
}
return string.IsNullOrWhiteSpace(parsedImageUrl) ? null : parsedImageUrl.Trim();
return new List<CreateSessionCommand>
{
BuildCommand(
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
isOneShot: true)
};
}
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers,
bool isOneShot)
{
var gmId = draft.OwnerTelegramId;
var user = new PlatformUser(
PlatformKind.Telegram,
gmId.ToString(System.Globalization.CultureInfo.InvariantCulture),
DisplayName: string.Empty,
ExternalUsername: null);
var group = new PlatformGroup(
PlatformKind.Telegram,
draft.ChatId.ToString(System.Globalization.CultureInfo.InvariantCulture),
DisplayName: string.Empty,
ExternalChannelId: null,
ExternalThreadId: draft.MessageThreadId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
return new CreateSessionCommand(
User: user,
Group: group,
Title: p.Title ?? string.Empty,
Link: string.Empty,
ScheduledTimes: scheduledTimes,
MaxPlayers: maxPlayers,
ImageReference: p.ImageFileId ?? p.ImageUrl,
System: ParseSystem(p.System),
Description: p.Description,
Format: null,
DurationMinutes: p.DurationMinutes,
IsOneShot: isOneShot);
}
private static GameSystem? ParseSystem(string? code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
return Enum.TryParse<GameSystem>(code, ignoreCase: true, out var sys) ? sys : null;
}
// ── Validation ───────────────────────────────────────────────────
private static bool IsComplete(WizardPayload p, out string missing)
{
var missingFields = new List<string>();
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
if (p.Visibility is null) missingFields.Add("видимость");
if (p.Type == WizardCreationType.Single)
{
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
}
else
{
if (p.Pool is null || p.Pool.Slots.Count == 0) missingFields.Add("слоты");
}
missing = string.Join(", ", missingFields);
return missingFields.Count == 0;
}
// ── Payload I/O ──────────────────────────────────────────────────
private static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return JsonSerializer.Deserialize(draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload p)
{
draft.PayloadJson = JsonSerializer.Serialize(p, WizardPayloadJsonContext.Default.WizardPayload);
}
// ── Keyboards ────────────────────────────────────────────────────
private static InlineKeyboardMarkup EmptyKeyboard() => new(Array.Empty<InlineKeyboardButton[]>());
private static InlineKeyboardMarkup RetryCancelKeyboard() => new(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🔁 Повторить", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
}
@@ -1,184 +0,0 @@
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record NewSessionParseResult(
string? Title,
string? Link,
string? ImageUrl,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
IReadOnlyList<string> PastTimeInputs,
IReadOnlyList<string> InvalidTimeInputs,
IReadOnlyList<string> InvalidSeatLimitInputs,
IReadOnlyList<string> InvalidRecurringInputs)
{
public bool IsValid =>
!string.IsNullOrWhiteSpace(Title) &&
!string.IsNullOrWhiteSpace(Link) &&
ScheduledTimes.Count > 0 &&
InvalidSeatLimitInputs.Count == 0 &&
InvalidRecurringInputs.Count == 0;
}
internal static class NewSessionCommandParser
{
private const int MaxRecurringSessionCount = 52;
private const int MaxRecurringIntervalDays = 365;
private const string TitlePrefix = "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435:";
private const string TimePrefix = "\u0412\u0440\u0435\u043c\u044f:";
private const string LinkPrefix = "\u0421\u0441\u044b\u043b\u043a\u0430:";
private static readonly string[] ImagePrefixes =
[
"\u041a\u0430\u0440\u0442\u0438\u043d\u043a\u0430:",
"\u0418\u0437\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u0438\u0435:",
"\u041e\u0431\u043b\u043e\u0436\u043a\u0430:"
];
private static readonly string[] SeatLimitPrefixes =
[
"\u041c\u0435\u0441\u0442:",
"\u041b\u0438\u043c\u0438\u0442:",
"\u041c\u0430\u043a\u0441\u0438\u043c\u0443\u043c:"
];
private static readonly string[] RecurringCountPrefixes =
[
"\u0418\u0433\u0440:",
"\u0421\u0435\u0441\u0441\u0438\u0439:",
"\u041f\u043e\u0432\u0442\u043e\u0440\u043e\u0432:"
];
private static readonly string[] RecurringIntervalPrefixes =
[
"\u0428\u0430\u0433:",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b:"
];
public static NewSessionParseResult Parse(string? text, DateTimeOffset nowUtc)
{
string? title = null;
string? link = null;
string? imageUrl = null;
int? maxPlayers = null;
int? recurringCount = null;
var recurringIntervalDays = 7;
var scheduledTimes = new List<DateTimeOffset>();
var pastTimeInputs = new List<string>();
var invalidTimeInputs = new List<string>();
var invalidSeatLimitInputs = new List<string>();
var invalidRecurringInputs = new List<string>();
foreach (var line in (text ?? string.Empty).Split('\n', StringSplitOptions.TrimEntries))
{
if (line.StartsWith(TitlePrefix, StringComparison.OrdinalIgnoreCase))
{
title = line[TitlePrefix.Length..].Trim();
continue;
}
if (line.StartsWith(LinkPrefix, StringComparison.OrdinalIgnoreCase))
{
link = line[LinkPrefix.Length..].Trim();
continue;
}
var imagePrefix = ImagePrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (imagePrefix is not null)
{
imageUrl = line[imagePrefix.Length..].Trim();
continue;
}
var seatLimitPrefix = SeatLimitPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (seatLimitPrefix is not null)
{
var seatLimitInput = line[seatLimitPrefix.Length..].Trim();
if (int.TryParse(seatLimitInput, out var parsedMaxPlayers) && parsedMaxPlayers > 0)
{
maxPlayers = parsedMaxPlayers;
}
else
{
invalidSeatLimitInputs.Add(seatLimitInput);
}
continue;
}
var recurringCountPrefix = RecurringCountPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringCountPrefix is not null)
{
var recurringInput = line[recurringCountPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedCount) &&
parsedCount is >= 1 and <= MaxRecurringSessionCount)
{
recurringCount = parsedCount;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
var recurringIntervalPrefix = RecurringIntervalPrefixes.FirstOrDefault(prefix =>
line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
if (recurringIntervalPrefix is not null)
{
var recurringInput = line[recurringIntervalPrefix.Length..].Trim();
if (int.TryParse(recurringInput, out var parsedInterval) &&
parsedInterval is >= 1 and <= MaxRecurringIntervalDays)
{
recurringIntervalDays = parsedInterval;
}
else
{
invalidRecurringInputs.Add(recurringInput);
}
continue;
}
if (!line.StartsWith(TimePrefix, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var timeInput = line[TimePrefix.Length..].Trim();
if (!MoscowTime.TryParseMoscow(timeInput, out var scheduledAt))
{
invalidTimeInputs.Add(timeInput);
continue;
}
if (scheduledAt <= nowUtc)
{
pastTimeInputs.Add(timeInput);
continue;
}
scheduledTimes.Add(scheduledAt);
}
if (recurringCount.HasValue && scheduledTimes.Count == 1)
{
var firstScheduledTime = scheduledTimes[0];
scheduledTimes = Enumerable.Range(0, recurringCount.Value)
.Select(index => firstScheduledTime.AddDays(recurringIntervalDays * index))
.ToList();
}
return new NewSessionParseResult(
title,
link,
imageUrl,
maxPlayers,
scheduledTimes,
pastTimeInputs,
invalidTimeInputs,
invalidSeatLimitInputs,
invalidRecurringInputs);
}
}
@@ -0,0 +1,504 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Central state machine for the game/pool creation wizard.
/// </summary>
public sealed class GameCreationWizard
{
private readonly IWizardDraftRepository _drafts;
private readonly ITelegramWizardMessenger _messenger;
private readonly ILogger<GameCreationWizard> _log;
public GameCreationWizard(
IWizardDraftRepository drafts,
ITelegramWizardMessenger messenger,
ILogger<GameCreationWizard> log)
{
_drafts = drafts;
_messenger = messenger;
_log = log;
}
/// <summary>Handle a text or callback update from the owning GM.</summary>
public async Task HandleUpdateAsync(Update update, WizardDraft draft, CancellationToken ct)
{
try
{
if (update.CallbackQuery is { } cb)
{
await HandleCallbackAsync(draft, cb, ct);
}
else if (update.Message is { } msg)
{
await HandleTextAsync(draft, msg, ct);
}
}
catch (WizardStorageException)
{
// Surface storage failure; do not crash the update loop.
if (update.CallbackQuery is { } cb2)
{
await _messenger.AnswerCallbackAsync(cb2.Id, "💥 Ошибка хранилища, попробуйте /newsession", ct);
}
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard update failed for draft {DraftId}", draft.Id);
if (update.CallbackQuery is { } cb3)
{
try { await _messenger.AnswerCallbackAsync(cb3.Id, "⚠️ Ошибка", ct); }
catch { /* swallow — we're already in error path */ }
}
}
}
private async Task HandleCallbackAsync(WizardDraft draft, CallbackQuery cb, CancellationToken ct)
{
if (!WizardCallbackData.TryParse(cb.Data, out var action, out var step, out var choice))
{
await _messenger.AnswerCallbackAsync(cb.Id, "Неизвестная команда", ct);
return;
}
switch (action)
{
case "cancel":
await _drafts.DeleteAsync(draft.Id, ct);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
"❌ Мастер отменён.", EmptyKeyboard, ct);
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
return;
case "back":
ApplyBack(draft, step);
await PersistAndRenderAsync(draft, cb.Id, ct);
return;
case "create":
// Routed by CreateSessionHandler, not here.
await _messenger.AnswerCallbackAsync(cb.Id, null, ct);
return;
default:
// For "Choice" callbacks, action == step.
await ApplyChoiceAsync(draft, step, choice, cb.Id, ct);
return;
}
}
private async Task HandleTextAsync(WizardDraft draft, Message msg, CancellationToken ct)
{
if (msg.Text is not { } text)
{
// Photo or other non-text — handle cover step only.
if (msg.Photo is { Length: > 0 } && draft.Step == WizardStepNames.Cover)
{
var fileId = msg.Photo[^1].FileId;
ApplyCoverPhoto(draft, fileId);
await PersistAndRenderAsync(draft, null, ct);
}
return;
}
var (nextStep, error, payload) = ApplyText(draft, text);
if (payload is { } p) SavePayload(draft, p);
if (error is { } errMsg && draft.DraftMessageId is { } mid)
{
// Re-render the same step with ⚠️ prefix.
var (rendered, kb) = WizardStep.Render(draft, LoadPayload(draft), null);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, mid,
"⚠️ " + errMsg + "\n\n" + rendered, kb, ct);
return;
}
if (nextStep is { } step)
{
draft.Step = step;
}
await PersistAndRenderAsync(draft, null, ct);
}
private async Task ApplyChoiceAsync(WizardDraft draft, string step, string choice, string callbackId, CancellationToken ct)
{
var (nextStep, error, payload) = ApplyChoice(draft, step, choice);
if (error is { } err)
{
await _messenger.AnswerCallbackAsync(callbackId, err, ct);
return;
}
if (payload is { } p) SavePayload(draft, p);
if (nextStep is { } s)
{
draft.Step = s;
}
await PersistAndRenderAsync(draft, callbackId, ct);
}
private async Task PersistAndRenderAsync(WizardDraft draft, string? callbackId, CancellationToken ct)
{
draft.UpdatedAt = DateTimeOffset.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null;
if (draft.Step == WizardStepNames.PickClub)
{
clubs = await _messenger.GetGmClubsAsync(draft.OwnerTelegramId, ct);
}
var (text, kb) = WizardStep.Render(draft, payload, clubs);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
text, kb, ct);
if (callbackId is { } id)
{
await _messenger.AnswerCallbackAsync(id, null, ct);
}
}
// ── Text input dispatcher ─────────────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input)
{
var payload = LoadPayload(draft);
switch (draft.Step)
{
case WizardStepNames.Title:
return ValidateText(input, WizardStep.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title)
? (WizardStepNames.Description, SetTitle(payload, title), payload)
: (null, title, payload);
case WizardStepNames.Description:
if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload);
return ValidateText(input, WizardStep.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc)
? (WizardStepNames.Cover, SetDescription(payload, desc), payload)
: (null, desc, payload);
case WizardStepNames.Cover:
if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload);
if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
return (NextAfterCover(payload), SetImageUrl(payload, input), payload);
return (null, "Некорректный URL", payload);
case WizardStepNames.System when payload.System is null:
// "Other" branch — only active if free-text was offered.
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys)
? (WizardStepNames.Duration, SetSystem(payload, sys), payload)
: (null, sys, payload);
case WizardStepNames.Duration when payload.DurationMinutes is null:
return TryParseHours(input, out var durMin)
? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.DateTime:
return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow
? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload)
: (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
return int.TryParse(input, out var cap) && cap >= WizardStep.MinCapacity && cap <= WizardStep.MaxCapacity
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
: (null, "Лимит должен быть 1..50", payload);
case WizardStepNames.PoolSystemDuration when payload.System is null:
return ValidateText(input, WizardStep.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
: (null, psys, payload);
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
return TryParseHours(input, out var pdur)
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
: (null, "Неверная длительность (1..12 ч)", payload);
case WizardStepNames.PoolSlotDateTime:
return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow
? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload)
: (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload);
case WizardStepNames.PoolSlotCapacity:
return int.TryParse(input, out var slotCap) && slotCap >= WizardStep.MinCapacity && slotCap <= WizardStep.MaxCapacity
? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload)
: (null, "Лимит должен быть 1..50", payload);
default:
return (null, "Ожидается выбор кнопкой", payload);
}
}
// ── Callback (button) dispatcher ──────────────────────────────────
private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice)
{
var payload = LoadPayload(draft);
var (next, err) = step switch
{
WizardStepNames.Type => ApplyTypeChoice(payload, choice),
WizardStepNames.System => ApplySystemChoice(payload, choice),
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice),
WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice),
WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice),
_ => (null, "Неизвестный шаг"),
};
return (next, err, payload);
}
private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch
{
"single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)),
"pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.System, null), // stay, await text
"_skip" => (NextAfterSystem(p), SetSystem(p, null)),
{ } code => (WizardStepNames.Duration, SetSystem(p, code)),
};
private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch
{
"_other" => (WizardStepNames.Duration, null),
"_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)),
{ } d => int.TryParse(d, out var min)
? (NextAfterDuration(p), SetDurationMinutes(p, min))
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
"public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)),
"club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)),
"members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)),
"pickclub" => (WizardStepNames.PickClub, null),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
"yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)),
"no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch
{
"_custom" => (WizardStepNames.PoolSystemDuration, null),
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
: (null, "Неверный выбор"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch
{
"add" => BeginNewPoolSlot(p),
"done" => p.Pool?.Slots.Count > 0
? (WizardStepNames.PoolConfirm, null)
: (null, "Добавьте хотя бы один слот"),
_ => (null, "Неизвестный выбор"),
};
private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch
{
"waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)),
"waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)),
_ => (null, "Неизвестный выбор"),
};
// ── Back navigation ───────────────────────────────────────────────
private static void ApplyBack(WizardDraft draft, string fromStep)
{
// The callback's "step" portion is the step the user is currently on (e.g. the
// Confirm button emits `wizard:back` with no step, in which case we fall back to
// the draft's current step). Both should produce the same result.
var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep;
var payload = LoadPayload(draft);
var previous = PreviousStep(current, payload);
if (previous is { } step) draft.Step = step;
}
private static string? PreviousStep(string step, WizardPayload p) => step switch
{
WizardStepNames.Title => null, // first step
WizardStepNames.Description => WizardStepNames.Title,
WizardStepNames.Cover => WizardStepNames.Description,
WizardStepNames.System => WizardStepNames.Cover,
WizardStepNames.Duration => WizardStepNames.System,
WizardStepNames.DateTime => WizardStepNames.Duration,
WizardStepNames.Capacity => WizardStepNames.DateTime,
WizardStepNames.Visibility => WizardStepNames.Capacity,
WizardStepNames.PickClub => WizardStepNames.Visibility,
WizardStepNames.Publish => WizardStepNames.PickClub,
WizardStepNames.Confirm => WizardStepNames.Publish,
WizardStepNames.PoolSystemDuration => null, // first pool step
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
_ => null,
};
// ── Payload I/O ───────────────────────────────────────────────────
internal static WizardPayload LoadPayload(WizardDraft draft)
{
if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload();
return System.Text.Json.JsonSerializer.Deserialize(
draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload();
}
private static void SavePayload(WizardDraft draft, WizardPayload payload)
{
draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload, WizardPayloadJsonContext.Default.WizardPayload);
}
// Mutators — return the error message if any (kept here to centralise flow).
private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; }
private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; }
private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; }
private static void ApplyCoverPhoto(WizardDraft d, string fileId)
{
var p = LoadPayload(d);
p.ImageFileId = fileId;
p.ImageUrl = null;
SavePayload(d, p);
var next = NextAfterCover(p);
if (next is { } s) d.Step = s;
}
private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; }
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.ScheduledAt = v;
return null;
}
private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.MaxPlayers = v;
return null;
}
private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist)
{
p.Pool ??= new WizardPoolInput();
var current = EnsureCurrentPoolSlot(p);
current.Waitlist = waitlist;
return null;
}
private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p)
{
p.Pool ??= new WizardPoolInput();
p.Pool.Slots.Add(new WizardSlotInput());
return (WizardStepNames.PoolSlotDateTime, null);
}
private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p)
{
// Slots added via BeginNewPoolSlot are always committed before they leave the
// PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without
// a slot, start a new one to keep the flow recoverable.
p.Pool ??= new WizardPoolInput();
var last = p.Pool.Slots.LastOrDefault();
if (last is not null && last.MaxPlayers == 0) return last;
p.Pool.Slots.Add(new WizardSlotInput());
return p.Pool.Slots[^1];
}
// ── Flow helpers ──────────────────────────────────────────────────
private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool
? WizardStepNames.PoolSystemDuration : WizardStepNames.System;
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
private static string? NextAfterDuration(WizardPayload p)
{
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
}
private static string? NextAfterVisibility(WizardPayload p)
{
if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members)
{
if (p.ClubId is null) return WizardStepNames.PickClub;
}
return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish;
}
private static (string? sys, int? dur) SplitSystemDuration(string s)
{
var idx = s.IndexOf(':');
if (idx <= 0 || idx >= s.Length - 1) return (null, null);
var sys = s.Substring(0, idx);
if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null);
return (sys, durMin);
}
private static bool ValidateText(
string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed)
{
trimmed = input.Trim();
if (string.IsNullOrEmpty(trimmed))
{
trimmed = emptyMsg;
return false;
}
if (trimmed.Length > maxLength)
{
trimmed = tooLongMsg;
return false;
}
return true;
}
private static bool TryParseHours(string input, out int minutes)
{
minutes = 0;
var s = input.Trim();
if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1);
if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false;
if (hours < WizardStep.MinDurationHours || hours > WizardStep.MaxDurationHours) return false;
minutes = (int)Math.Round(hours * 60);
return true;
}
private static readonly InlineKeyboardMarkup EmptyKeyboard = new(Array.Empty<InlineKeyboardButton[]>());
}
@@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed record WizardClubOption(Guid ClubId, string Name);
public interface ITelegramWizardMessenger
{
Task<long> EditMessageTextAsync(long chatId, int? messageThreadId, long messageId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
Task<long> SendGroupMessageAsync(long chatId, int? messageThreadId, string text, Telegram.Bot.Types.ReplyMarkups.InlineKeyboardMarkup keyboard, CancellationToken ct);
Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct);
Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct);
}
@@ -0,0 +1,70 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
using Telegram.Bot;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class TelegramWizardMessenger(
ITelegramBotClient bot,
NpgsqlDataSource dataSource) : ITelegramWizardMessenger
{
public async Task<long> EditMessageTextAsync(
long chatId, int? messageThreadId, long messageId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
{
var msg = await bot.EditMessageText(
chatId: chatId,
messageId: (int)messageId,
text: text,
replyMarkup: keyboard,
cancellationToken: ct);
return msg.MessageId;
}
public async Task<long> SendGroupMessageAsync(
long chatId, int? messageThreadId, string text,
InlineKeyboardMarkup keyboard, CancellationToken ct)
{
var msg = await bot.SendMessage(
chatId: chatId,
text: text,
messageThreadId: messageThreadId,
replyMarkup: keyboard,
cancellationToken: ct);
return msg.MessageId;
}
public async Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
{
await bot.AnswerCallbackQuery(callbackId, text: text, cancellationToken: ct);
}
public async Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
{
// Adjusted from the plan: this codebase models "clubs" as game_groups
// (V001 created game_groups; V026 added public_slug; no `clubs` table exists,
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
const string sql = """
SELECT g.id AS ClubId,
g.name AS Name
FROM game_groups g
JOIN group_managers gm ON gm.group_id = g.id
JOIN players p ON p.id = gm.player_id
WHERE p.platform = 'Telegram'
AND p.external_user_id = @ExternalId
GROUP BY g.id, g.name
ORDER BY g.name
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>(
new CommandDefinition(sql, new { ExternalId = ownerTelegramId.ToString() }, cancellationToken: ct));
return rows.AsList();
}
}
@@ -0,0 +1,25 @@
using System;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public static class WizardCallbackData
{
public const string Prefix = "wizard";
public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}";
public static string Back() => $"{Prefix}:back";
public static string Cancel() => $"{Prefix}:cancel";
public static string Create() => $"{Prefix}:create";
public static bool TryParse(string? data, out string action, out string step, out string choice)
{
action = step = choice = string.Empty;
if (string.IsNullOrEmpty(data)) return false;
var parts = data.Split(':', 3);
if (parts.Length < 2 || parts[0] != Prefix) return false;
action = parts[1];
step = parts.Length >= 3 ? parts[1] : string.Empty;
choice = parts.Length >= 3 ? parts[2] : string.Empty;
return true;
}
}
@@ -0,0 +1,60 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftCleanupService : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private readonly IWizardDraftRepository _drafts;
private readonly ILogger<WizardDraftCleanupService> _log;
public WizardDraftCleanupService(
IWizardDraftRepository drafts,
ILogger<WizardDraftCleanupService> log)
{
_drafts = drafts;
_log = log;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TickInterval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RunOnceAsync(stoppingToken);
}
}
catch (OperationCanceledException)
{
// graceful shutdown
}
}
internal async Task RunOnceAsync(CancellationToken ct)
{
try
{
var deleted = await _drafts.DeleteExpiredAsync(ct);
if (deleted > 0)
{
_log.LogInformation("Wizard cleanup deleted {Count} expired drafts", deleted);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_log.LogError(ex, "Wizard cleanup tick failed");
}
}
}
@@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
using System.Text;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public static class WizardStep
{
public const int MaxTitleLength = 200;
public const int MaxDescriptionLength = 4000;
public const int MaxSystemLength = 100;
public const int MaxCapacity = 50;
public const int MinCapacity = 1;
public const int MinDurationHours = 1;
public const int MaxDurationHours = 12;
public static (string text, InlineKeyboardMarkup keyboard) Render(
WizardDraft draft,
WizardPayload payload,
IReadOnlyList<WizardClubOption>? clubs = null)
{
return draft.Step switch
{
WizardStepNames.Type => RenderType(),
WizardStepNames.Title => RenderTitle(),
WizardStepNames.Description => RenderDescription(),
WizardStepNames.Cover => RenderCover(),
WizardStepNames.System => RenderSystem(),
WizardStepNames.Duration => RenderDuration(),
WizardStepNames.DateTime => RenderDateTime(),
WizardStepNames.Capacity => RenderCapacity(),
WizardStepNames.Visibility => RenderVisibility(),
WizardStepNames.PickClub => RenderPickClub(clubs ?? Array.Empty<WizardClubOption>()),
WizardStepNames.Publish => RenderPublish(),
WizardStepNames.Confirm => RenderSingleConfirm(payload),
WizardStepNames.PoolSystemDuration => RenderPoolSystemDuration(),
WizardStepNames.PoolAddSlots => RenderPoolAddSlots(payload),
WizardStepNames.PoolSlotDateTime => RenderPoolSlotDateTime(),
WizardStepNames.PoolSlotCapacity => RenderPoolSlotCapacity(),
WizardStepNames.PoolConfirm => RenderPoolConfirm(payload),
_ => throw new InvalidOperationException($"Unknown wizard step: {draft.Step}"),
};
}
// ── Single-game renderers ──────────────────────────────────────────
private static (string, InlineKeyboardMarkup) RenderType() => (
"🎲 Создание новой игровой сессии\n\nЧто создаём?",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🎯 Одну игру", WizardCallbackData.Choice(WizardStepNames.Type, "single")) },
new[] { InlineKeyboardButton.WithCallbackData("📅 Пул игр", WizardCallbackData.Choice(WizardStepNames.Type, "pool")) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
}));
private static (string, InlineKeyboardMarkup) RenderTitle() => (
"📝 Введите название игры одним сообщением.",
BackCancel());
private static (string, InlineKeyboardMarkup) RenderDescription() => (
"📄 Введите описание (или «-», чтобы пропустить).",
SkipBackCancel());
private static (string, InlineKeyboardMarkup) RenderCover() => (
"🖼 Пришлите картинку как вложение или URL (или «-»).",
SkipBackCancel());
private static (string, InlineKeyboardMarkup) RenderSystem()
{
var buttons = new List<InlineKeyboardButton[]>
{
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e", WizardCallbackData.Choice(WizardStepNames.System, "Dnd5e")) },
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e", WizardCallbackData.Choice(WizardStepNames.System, "Pathfinder2e")) },
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu",WizardCallbackData.Choice(WizardStepNames.System, "CallOfCthulhu7e")) },
new[] { InlineKeyboardButton.WithCallbackData("GURPS", WizardCallbackData.Choice(WizardStepNames.System, "GURPS")) },
new[] { InlineKeyboardButton.WithCallbackData("Fate", WizardCallbackData.Choice(WizardStepNames.System, "Fate")) },
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.System, "_other")) },
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.System, "_skip")) },
};
return ("🎲 Выберите систему.", new InlineKeyboardMarkup(buttons).AppendBackCancel());
}
private static (string, InlineKeyboardMarkup) RenderDuration() => (
"⏱ Выберите длительность.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("3 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "180")) },
new[] { InlineKeyboardButton.WithCallbackData("4 часа", WizardCallbackData.Choice(WizardStepNames.Duration, "240")) },
new[] { InlineKeyboardButton.WithCallbackData("5 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "300")) },
new[] { InlineKeyboardButton.WithCallbackData("6 часов", WizardCallbackData.Choice(WizardStepNames.Duration, "360")) },
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.Duration, "_other")) },
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice(WizardStepNames.Duration, "_skip")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderDateTime() => (
"📅 Введите дату и время в формате ДД.ММ.ГГГГ ЧЧ:ММ (Москва).",
BackCancel());
private static (string, InlineKeyboardMarkup) RenderCapacity() => (
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on")) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderVisibility() => (
"🔒 Выберите видимость.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("🌐 Публичная в общем showcase", WizardCallbackData.Choice(WizardStepNames.Visibility, "public")) },
new[] { InlineKeyboardButton.WithCallbackData("🏠 Публичная в витрине клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "club")) },
new[] { InlineKeyboardButton.WithCallbackData("🔐 Только для членов клуба", WizardCallbackData.Choice(WizardStepNames.Visibility, "members")) },
new[] { InlineKeyboardButton.WithCallbackData("🏷 Выбрать клуб…", WizardCallbackData.Choice(WizardStepNames.Visibility, "pickclub")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPickClub(IReadOnlyList<WizardClubOption> clubs)
{
if (clubs.Count == 0)
{
return (
"🏷 У вас нет клубов. Создайте клуб в Web dashboard и вернитесь.",
BackCancel());
}
var rows = new List<InlineKeyboardButton[]>();
foreach (var club in clubs)
{
rows.Add(new[]
{
InlineKeyboardButton.WithCallbackData(club.Name, WizardCallbackData.Choice(WizardStepNames.PickClub, club.ClubId.ToString()))
});
}
return ("🏷 Выберите клуб:", new InlineKeyboardMarkup(rows).AppendBackCancel());
}
private static (string, InlineKeyboardMarkup) RenderPublish() => (
"✨ Опубликовать в витрине сейчас?",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Опубликовать", WizardCallbackData.Choice(WizardStepNames.Publish, "yes")) },
new[] { InlineKeyboardButton.WithCallbackData("📝 Только в чате", WizardCallbackData.Choice(WizardStepNames.Publish, "no")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderSingleConfirm(WizardPayload p)
{
var sb = new StringBuilder();
sb.AppendLine("👀 Проверьте перед созданием:");
sb.AppendLine();
sb.AppendLine($"🎲 {p.Title}");
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
return (sb.ToString(), new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
}));
}
// ── Pool renderers ─────────────────────────────────────────────────
private static (string, InlineKeyboardMarkup) RenderPoolSystemDuration() => (
"🎲 Выберите систему и длительность пула.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("D&D 5e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240")) },
new[] { InlineKeyboardButton.WithCallbackData("Pathfinder 2e · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Pathfinder2e:240")) },
new[] { InlineKeyboardButton.WithCallbackData("Call of Cthulhu · 3 ч",WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "CallOfCthulhu7e:180")) },
new[] { InlineKeyboardButton.WithCallbackData("GURPS · 4 ч", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "GURPS:240")) },
new[] { InlineKeyboardButton.WithCallbackData("Другое… ✏️", WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "_custom")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolAddSlots(WizardPayload p) => (
$"📅 Слоты пула «{p.Title}»\n\nДобавлено: {(p.Pool?.Slots.Count ?? 0)}",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("➕ Добавить слот", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")) },
new[] { InlineKeyboardButton.WithCallbackData("✅ Готово, к превью", WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolSlotDateTime() => (
"📅 Введите дату/время слота (ДД.ММ.ГГГГ ЧЧ:ММ).",
BackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolSlotCapacity() => (
"👥 Введите лимит мест (1..50) и выберите waitlist.",
new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on")) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off")) },
}).AppendBackCancel());
private static (string, InlineKeyboardMarkup) RenderPoolConfirm(WizardPayload p)
{
var sb = new StringBuilder();
sb.AppendLine("👀 Проверьте пул перед созданием:");
sb.AppendLine();
sb.AppendLine($"📝 {p.Title}");
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
sb.AppendLine();
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
if (p.Pool is not null)
{
foreach (var s in p.Pool.Slots)
{
sb.AppendLine($" • {s.ScheduledAt.FormatMoscow()} — мест {s.MaxPlayers}, waitlist {(s.Waitlist ? "вкл" : "выкл")}");
}
}
return (sb.ToString(), new InlineKeyboardMarkup(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("✅ Создать пул", WizardCallbackData.Create()) },
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
}));
}
// ── Helpers ────────────────────────────────────────────────────────
private static InlineKeyboardMarkup BackCancel() => new(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private static InlineKeyboardMarkup SkipBackCancel() => new(new[]
{
new[] { InlineKeyboardButton.WithCallbackData("⏭ Пропустить", WizardCallbackData.Choice("Skip", "1")) },
new[] { InlineKeyboardButton.WithCallbackData("⬅️ Назад", WizardCallbackData.Back()) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private static string RenderVisibilityText(WizardVisibility? v) => v switch
{
WizardVisibility.Public => "публичная в общем showcase",
WizardVisibility.Club => "публичная в витрине клуба",
WizardVisibility.Members => "только для членов клуба",
_ => "не задана",
};
}
internal static class InlineKeyboardMarkupExtensions
{
public static InlineKeyboardMarkup AppendBackCancel(this InlineKeyboardMarkup kb) => kb;
}
@@ -0,0 +1,24 @@
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public static class WizardStepNames
{
public const string Type = "Type";
public const string Title = "Title";
public const string Description = "Description";
public const string Cover = "Cover";
public const string System = "System";
public const string Duration = "Duration";
public const string DateTime = "DateTime";
public const string Capacity = "Capacity";
public const string Visibility = "Visibility";
public const string PickClub = "PickClub";
public const string Publish = "Publish";
public const string Confirm = "Confirm";
// Pool steps
public const string PoolSystemDuration = "PoolSystemDuration";
public const string PoolAddSlots = "PoolAddSlots";
public const string PoolSlotDateTime = "PoolSlotDateTime";
public const string PoolSlotCapacity = "PoolSlotCapacity";
public const string PoolConfirm = "PoolConfirm";
}
@@ -0,0 +1,8 @@
using System;
namespace GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
public sealed class WizardStorageException : Exception
{
public WizardStorageException(string message, Exception inner) : base(message, inner) { }
}
@@ -2,8 +2,10 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Rendering;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.ListSessions;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using BotRescheduleTimeInputHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
@@ -34,12 +36,44 @@ public sealed class UpdateRouter(
InitiateRescheduleHandler initiateRescheduleHandler,
BotRescheduleTimeInputHandler rescheduleTimeInputHandler,
BotRescheduleVoteHandler rescheduleVoteHandler,
GameCreationWizard wizard,
IWizardDraftRepository drafts,
ITelegramBotClient bot,
IConfiguration configuration,
ILogger<UpdateRouter> logger) : ITelegramUpdateHandler
{
public async Task RouteAsync(Update update, CancellationToken ct)
{
// 1) Wizard delegation. If the GM has an active (non-expired) draft for this
// (chat, thread, owner), every update routes to the wizard. The wizard is
// responsible for both text input and callback handling.
if (TryGetWizardContext(update, out var chatId, out var threadId, out var ownerId))
{
var draft = await drafts.GetActiveAsync(chatId, threadId, ownerId, ct);
if (draft is not null)
{
// Resume / Reset / Cancel menu callbacks live in the router because
// they cross draft boundaries (reset deletes + recreates a fresh
// draft, which the wizard instance doesn't know how to do).
if (await TryHandleDraftControlCallbackAsync(update, draft, ct))
{
return;
}
await wizard.HandleUpdateAsync(update, draft, ct);
// The "✅ Создать" / "✅ Создать пул" button — the wizard only
// acknowledges the callback; the actual session creation lives in
// CreateSessionHandler.
if (update.CallbackQuery?.Data is { } data &&
data == WizardCallbackData.Create())
{
await createSessionHandler.SubmitDraftAsync(draft, ct);
}
return;
}
}
switch (update)
{
case { CallbackQuery: { } query }:
@@ -63,9 +97,106 @@ public sealed class UpdateRouter(
}
}
/// <summary>
/// Handles router-level draft-control callbacks ("resume", "reset"). Returns true
/// if the update was consumed and the wizard should be skipped. The wizard still
/// owns "cancel" and "create".
/// </summary>
private async Task<bool> TryHandleDraftControlCallbackAsync(
Update update, WizardDraft draft, CancellationToken ct)
{
if (update.CallbackQuery is not { Data: { } data, Message: { } cbMessage, From: { } cbFrom })
return false;
switch (data)
{
case WizardControlCallbacks.Resume:
// Re-render the current step of the existing draft. We answer the
// callback here because the wizard will not be called.
var (text, kb) = WizardStep.Render(draft, LoadPayload(draft));
await bot.EditMessageText(
chatId: cbMessage.Chat.Id,
messageId: cbMessage.MessageId,
text: text,
replyMarkup: kb,
cancellationToken: ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
return true;
case WizardControlCallbacks.Reset:
// Delete the existing draft and start a fresh one. The wizard is
// bypassed entirely because the active draft is now gone.
await drafts.DeleteAsync(draft.Id, ct);
await bot.AnswerCallbackQuery(update.CallbackQuery.Id, cancellationToken: ct);
var newDraft = await createSessionHandler.StartWizardAsync(
SyntheticStartMessage(cbMessage.Chat.Id, cbMessage.MessageThreadId, cbFrom.Id), ct);
if (newDraft is null)
{
// Race: another wizard just started for the same owner. The
// user can simply re-run /newsession. We don't loop.
await bot.SendMessage(
chatId: cbMessage.Chat.Id,
text: "Не удалось начать заново — попробуйте ещё раз через /newsession.",
cancellationToken: ct);
}
return true;
}
return false;
}
/// <summary>
/// Build a synthetic <see cref="Message"/> carrying just the fields
/// <see cref="CreateSessionHandler.StartWizardAsync"/> reads (chat, thread, from).
/// </summary>
private static Message SyntheticStartMessage(long chatId, int? messageThreadId, long fromId) => new()
{
Chat = new Chat { Id = chatId },
MessageThreadId = messageThreadId,
From = new User { Id = fromId },
};
private static WizardPayload LoadPayload(WizardDraft draft) =>
GameCreationWizard.LoadPayload(draft);
internal static string GetCommandText(Message message)
=> (message.Text ?? message.Caption ?? string.Empty).TrimStart();
/// <summary>
/// Extracts the (chat, thread, owner) triple from an update for wizard lookups.
/// Returns false for updates that carry no usable origin (e.g. inline queries).
/// </summary>
private static bool TryGetWizardContext(Update update, out long chatId, out int? messageThreadId, out long ownerId)
{
chatId = 0;
messageThreadId = null;
ownerId = 0;
switch (update)
{
case { Message: { From: not null, Chat: { } chat } msg }:
chatId = chat.Id;
messageThreadId = msg.MessageThreadId;
ownerId = msg.From!.Id;
return true;
case { CallbackQuery: { From: not null, Message: { Chat: { } cbmChat } } cb }:
chatId = cbmChat.Id;
messageThreadId = cb.Message?.MessageThreadId;
ownerId = cb.From!.Id;
return true;
case { CallbackQuery: { From: not null } cb2 }:
// Callback arrived without a message (e.g. from a Mini App). No chat
// context → wizard cannot run on this update.
ownerId = cb2.From!.Id;
return false;
default:
return false;
}
}
private async Task HandleCallbackQueryAsync(CallbackQuery query, CancellationToken ct)
{
if (query.Data is not { } data || query.Message is not { } message)
@@ -213,7 +344,7 @@ public sealed class UpdateRouter(
break;
case "/newsession":
await createSessionHandler.HandleAsync(message, ct);
await HandleNewSessionCommandAsync(message, ct);
break;
case "/listsessions":
@@ -256,6 +387,45 @@ public sealed class UpdateRouter(
}
}
private async Task HandleNewSessionCommandAsync(Message message, CancellationToken ct)
{
// Try to start a fresh wizard. StartWizardAsync returns null when a
// non-expired draft already exists for this (chat, thread, owner).
var draft = await createSessionHandler.StartWizardAsync(message, ct);
if (draft is not null)
{
// New draft was created and its first step has been rendered.
return;
}
// Existing draft. Look it up so we can describe the current step and offer
// a Continue / Start over / Cancel menu.
var existing = await createSessionHandler.TryResumeAsync(message, ct);
if (existing is null)
{
// Race: the draft expired between the two calls (or the user lacks
// ownership metadata). Fall back to silently starting a new wizard.
await createSessionHandler.StartWizardAsync(message, ct);
return;
}
await bot.SendMessage(
chatId: message.Chat.Id,
text: "У вас уже есть незавершённый мастер. Продолжить?",
replyMarkup: ContinueResetCancelKeyboard(),
cancellationToken: ct);
}
private InlineKeyboardMarkup ContinueResetCancelKeyboard() => new(new[]
{
// "Продолжить" re-renders the existing draft's current step (router-level).
// "Начать заново" deletes the existing draft and creates a fresh one (router-level).
// "Отмена" delegates to the wizard's normal cancel handler.
new[] { InlineKeyboardButton.WithCallbackData("➡️ Продолжить", WizardControlCallbacks.Resume) },
new[] { InlineKeyboardButton.WithCallbackData("🔁 Начать заново", WizardControlCallbacks.Reset) },
new[] { InlineKeyboardButton.WithCallbackData("❌ Отмена", WizardCallbackData.Cancel()) },
});
private async Task SendStartMessageAsync(Message message, CancellationToken ct)
{
var miniAppUrl = configuration["Telegram:MiniAppUrl"];
@@ -276,3 +446,14 @@ public sealed class UpdateRouter(
cancellationToken: ct);
}
}
/// <summary>
/// Router-level callback data for the Continue / Start over / Cancel menu shown
/// when /newsession detects an existing wizard draft. Distinct from
/// <see cref="WizardCallbackData"/> which is parsed and consumed by the wizard itself.
/// </summary>
internal static class WizardControlCallbacks
{
public const string Resume = "wizard:resume";
public const string Reset = "wizard:reset";
}
@@ -0,0 +1,66 @@
-- V030: Private club showcases. Adds club_memberships (member access control)
-- and replaces sessions.is_public with a 4-state publication_mode enum.
-- Backfills existing data: is_public=true → 'Both', is_public=false → 'None'.
-- portfolio_games gains the same enum (default 'Both' for pre-V030 rows).
-- 1. club_memberships
CREATE TABLE club_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (status IN ('Pending', 'Active', 'Rejected', 'Left')),
role VARCHAR(20) NOT NULL DEFAULT 'Member'
CHECK (role IN ('Member')),
message TEXT,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
decided_at TIMESTAMPTZ,
decided_by UUID REFERENCES players(id) ON DELETE SET NULL
);
-- Only one Active row per (group, player).
-- Re-application after Rejected/Left creates a new row.
CREATE UNIQUE INDEX ux_club_memberships_one_active
ON club_memberships (group_id, player_id)
WHERE status = 'Active';
CREATE INDEX ix_club_memberships_group_status
ON club_memberships (group_id, status);
CREATE INDEX ix_club_memberships_player_status
ON club_memberships (player_id, status);
-- 2. sessions.publication_mode (replaces is_public)
ALTER TABLE sessions
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'None';
-- Backfill before constraint so existing data maps cleanly.
UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true;
UPDATE sessions SET publication_mode = 'None' WHERE is_public = false;
ALTER TABLE sessions
ADD CONSTRAINT ck_sessions_publication_mode
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
ALTER TABLE sessions DROP COLUMN is_public;
DROP INDEX IF EXISTS ix_sessions_public_schedule;
DROP INDEX IF EXISTS ix_sessions_showcase;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE publication_mode IN ('Catalog', 'Both') AND status <> 'Cancelled';
-- 3. portfolio_games.publication_mode
-- Existing rows in portfolio_games keep 'Both' to stay visible to anonymous visitors.
ALTER TABLE portfolio_games
ADD COLUMN publication_mode VARCHAR(20) NOT NULL DEFAULT 'Both'
CHECK (publication_mode IN ('None', 'Catalog', 'ClubOnly', 'Both'));
CREATE INDEX ix_portfolio_games_showcase
ON portfolio_games (created_at DESC)
WHERE publication_mode IN ('Catalog', 'Both');
@@ -0,0 +1,21 @@
-- V031: Per-(chat, thread, owner) wizard drafts for the game-creation wizard (issue #111).
-- Stores in-progress wizard state in JSONB with a 24h TTL managed by WizardDraftCleanupService.
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
+8
View File
@@ -1,5 +1,6 @@
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using GmRelay.Bot.Infrastructure.Database;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -12,6 +13,7 @@ using GmRelay.Shared.Features.Notifications;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Infrastructure.Scheduling;
using GmRelay.Shared.Platform;
using Npgsql;
@@ -68,6 +70,11 @@ builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler>();
// Wizard services (issue #111)
builder.Services.AddSingleton<IWizardDraftRepository, WizardDraftRepository>();
builder.Services.AddSingleton<ITelegramWizardMessenger, TelegramWizardMessenger>();
builder.Services.AddSingleton<GameCreationWizard>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
builder.Services.AddSingleton<LeaveSessionHandler>();
@@ -101,6 +108,7 @@ builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
// ── Session scheduler ────────────────────────────────────────────────
builder.Services.AddHostedService<SessionSchedulerService>();
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
builder.Services.AddHostedService<WizardDraftCleanupService>();
// ── Health check server ──────────────────────────────────────────────
builder.Services.AddHostedService<BotHealthCheckHostedService>();
@@ -0,0 +1,44 @@
namespace GmRelay.Shared.Domain;
public enum PublicationMode
{
None,
Catalog,
ClubOnly,
Both
}
public static class PublicationModeExtensions
{
public const string NoneValue = nameof(PublicationMode.None);
public const string CatalogValue = nameof(PublicationMode.Catalog);
public const string ClubOnlyValue = nameof(PublicationMode.ClubOnly);
public const string BothValue = nameof(PublicationMode.Both);
public static bool IsVisibleInCatalog(this PublicationMode mode) =>
mode is PublicationMode.Catalog or PublicationMode.Both;
public static bool IsVisibleToClubMembers(this PublicationMode mode) =>
mode is PublicationMode.ClubOnly or PublicationMode.Both;
public static string ToDatabaseValue(this PublicationMode mode) =>
mode switch
{
PublicationMode.None => NoneValue,
PublicationMode.Catalog => CatalogValue,
PublicationMode.ClubOnly => ClubOnlyValue,
PublicationMode.Both => BothValue,
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, "Unknown publication mode.")
};
public static PublicationMode FromDatabaseValue(string? value) =>
value switch
{
null or "" => PublicationMode.None,
NoneValue => PublicationMode.None,
CatalogValue => PublicationMode.Catalog,
ClubOnlyValue => PublicationMode.ClubOnly,
BothValue => PublicationMode.Both,
_ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown publication mode.")
};
}
@@ -0,0 +1,21 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Storage contract for wizard drafts. Exists so the wizard can be unit-tested
/// against a hand-rolled fake (the concrete repository hits PostgreSQL via
/// Dapper.AOT and is therefore unsuitable for fast in-process tests).
/// </summary>
public interface IWizardDraftRepository
{
Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct);
Task UpsertAsync(WizardDraft draft, CancellationToken ct);
Task DeleteAsync(Guid id, CancellationToken ct);
Task<int> DeleteExpiredAsync(CancellationToken ct);
}
@@ -0,0 +1,17 @@
using System;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraft
{
public Guid Id { get; set; }
public long ChatId { get; set; }
public int? MessageThreadId { get; set; }
public long OwnerTelegramId { get; set; }
public string Step { get; set; } = string.Empty;
public string PayloadJson { get; set; } = "{}";
public long? DraftMessageId { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset ExpiresAt { get; set; }
}
@@ -0,0 +1,72 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
{
public async Task<WizardDraft?> GetActiveAsync(
long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
{
const string sql = """
SELECT id AS Id,
chat_id AS ChatId,
message_thread_id AS MessageThreadId,
owner_telegram_id AS OwnerTelegramId,
step AS Step,
payload::text AS PayloadJson,
draft_message_id AS DraftMessageId,
created_at AS CreatedAt,
updated_at AS UpdatedAt,
expires_at AS ExpiresAt
FROM wizard_drafts
WHERE chat_id = @ChatId
AND (message_thread_id = @ThreadId OR (@ThreadId IS NULL AND message_thread_id IS NULL))
AND owner_telegram_id = @OwnerId
AND expires_at > NOW()
LIMIT 1
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition(sql,
new { ChatId = chatId, ThreadId = messageThreadId, OwnerId = ownerTelegramId },
cancellationToken: ct));
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
const string sql = """
INSERT INTO wizard_drafts
(id, chat_id, message_thread_id, owner_telegram_id, step, payload, draft_message_id, created_at, updated_at, expires_at)
VALUES
(@Id, @ChatId, @MessageThreadId, @OwnerTelegramId, @Step, @PayloadJson::jsonb, @DraftMessageId, @CreatedAt, @UpdatedAt, @ExpiresAt)
ON CONFLICT (id) DO UPDATE
SET step = EXCLUDED.step,
payload = EXCLUDED.payload,
draft_message_id = EXCLUDED.draft_message_id,
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
}
public async Task DeleteAsync(Guid id, CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
}
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
}
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
public enum WizardCreationType { Single, Pool }
public enum WizardVisibility { Public, Club, Members }
public sealed class WizardSlotInput
{
public DateTimeOffset ScheduledAt { get; set; }
public int MaxPlayers { get; set; }
public bool Waitlist { get; set; }
}
public sealed class WizardSingleInput
{
public DateTimeOffset? ScheduledAt { get; set; }
public int? MaxPlayers { get; set; }
}
public sealed class WizardPayload
{
public WizardCreationType? Type { get; set; }
public string? Title { get; set; }
public string? Description { get; set; }
public string? ImageFileId { get; set; }
public string? ImageUrl { get; set; }
public string? System { get; set; }
public int? DurationMinutes { get; set; }
public WizardVisibility? Visibility { get; set; }
public Guid? ClubId { get; set; }
public bool? PublishInShowcase { get; set; }
public bool? Waitlist { get; set; }
public WizardSingleInput? Single { get; set; }
public WizardPoolInput? Pool { get; set; }
// Wizard-flow metadata (not a wizard step input).
[JsonIgnore]
public int RetryCount { get; set; }
}
public sealed class WizardPoolInput
{
public List<WizardSlotInput> Slots { get; set; } = new();
}
[JsonSerializable(typeof(WizardPayload))]
[JsonSerializable(typeof(WizardSingleInput))]
[JsonSerializable(typeof(WizardPoolInput))]
[JsonSerializable(typeof(WizardSlotInput))]
[JsonSourceGenerationOptions(
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
public partial class WizardPayloadJsonContext : JsonSerializerContext
{
}
@@ -18,5 +18,7 @@ public sealed record ShowcaseSessionDto(
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description,
string PublicationMode = "None",
bool IsMembersOnly = false,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
@@ -41,6 +41,15 @@
</svg>
Профиль
</NavLink>
<NavLink class="nav-item" href="profile/memberships" @onclick="CloseMenu">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 21h18"/>
<path d="M5 21V7l8-4v18"/>
<path d="M19 21V11l-6-4"/>
</svg>
Мои клубы
</NavLink>
</div>
<div class="nav-footer">
@@ -73,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.6.0</div>
<div class="nav-version">v3.7.1</div>
</div>
</Authorized>
<NotAuthorized>
@@ -6,7 +6,10 @@
<img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span>
</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
<div class="public-topbar-actions">
<a class="btn-gm btn-gm-outline" href="/showcase">Клубы</a>
<a class="btn-gm btn-gm-outline" href="/login">Войти</a>
</div>
</header>
<main class="public-content">
@@ -0,0 +1,151 @@
@page "/group/{GroupId:guid}/applications"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject AuthorizedMembershipService MembershipService
@inject AuthorizedSessionService SessionService
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using System.Security.Claims
<PageTitle>Заявки участников — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li><a href="@($"/group/{GroupId}")">Группа</a></li>
<li class="active">Заявки</li>
</ul>
<div class="page-header animate-fade-in">
<h2>📨 Заявки участников</h2>
<p>Одобряйте или отклоняйте заявки на участие в клубе.</p>
</div>
@if (accessDenied)
{
<div class="glass-card public-empty-state">
<h2>Нет доступа</h2>
<p>Только owner или co-GM группы могут просматривать заявки.</p>
</div>
}
else if (applications is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 70%; height: 2rem; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 90%;"></div>
</div>
}
else if (applications.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Новых заявок нет</h2>
<p>Когда игроки подадут заявку на участие в клубе, она появится здесь.</p>
</div>
}
else
{
<ul class="application-list">
@foreach (var app in applications)
{
<li class="glass-card application-item">
<div class="application-info">
<strong>@app.DisplayName</strong>
<span class="status-badge status-neutral">@app.Platform</span>
@if (!string.IsNullOrWhiteSpace(app.ExternalUsername))
{
<span class="application-meta">@app.ExternalUsername</span>
}
<span class="application-meta">@app.AppliedAt.ToString("dd.MM.yyyy HH:mm")</span>
@if (!string.IsNullOrWhiteSpace(app.Message))
{
<p class="application-message">«@app.Message»</p>
}
</div>
<div class="application-actions">
<button type="button" class="btn-gm btn-gm-success" disabled="@(busyMembershipId is not null)" @onclick="() => Approve(app.MembershipId)">
✅ Одобрить
</button>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(busyMembershipId is not null)" @onclick="() => Reject(app.MembershipId)">
❌ Отклонить
</button>
</div>
</li>
}
</ul>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
}
</div>
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebPendingApplication>? applications;
private bool accessDenied;
private string? errorMessage;
private Guid? busyMembershipId;
protected override async Task OnParametersSetAsync()
{
accessDenied = false;
try
{
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
}
catch (SessionAccessDeniedException)
{
accessDenied = true;
}
}
private async Task Approve(Guid membershipId)
{
errorMessage = null;
busyMembershipId = membershipId;
try
{
await MembershipService.ApproveForCurrentGmAsync(membershipId);
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
}
catch (SessionAccessDeniedException)
{
accessDenied = true;
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
finally
{
busyMembershipId = null;
}
}
private async Task Reject(Guid membershipId)
{
errorMessage = null;
busyMembershipId = membershipId;
try
{
await MembershipService.RejectForCurrentGmAsync(membershipId);
applications = await MembershipService.GetPendingApplicationsAsync(GroupId);
}
catch (SessionAccessDeniedException)
{
accessDenied = true;
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
finally
{
busyMembershipId = null;
}
}
}
@@ -57,6 +57,16 @@
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Режим публикации</label>
<InputSelect @bind-Value="model.PublicationMode" class="gm-form-control">
<option value="@PublicationModeExtensions.NoneValue">Скрыта</option>
<option value="@PublicationModeExtensions.CatalogValue">В каталоге</option>
<option value="@PublicationModeExtensions.ClubOnlyValue">Только для участников клуба</option>
<option value="@PublicationModeExtensions.BothValue">Каталог + клуб</option>
</InputSelect>
</div>
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
@@ -104,6 +114,7 @@
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
model.JoinLink = session.JoinLink;
model.MaxPlayers = session.MaxPlayers;
model.PublicationMode = session.PublicationMode;
}
private async Task HandleSubmit()
@@ -123,6 +134,7 @@
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
await SessionService.SetSessionPublicationModeForCurrentUserAsync(SessionId, PublicationModeExtensions.FromDatabaseValue(model.PublicationMode));
Navigation.NavigateTo($"/group/{session!.GroupId}");
}
catch (SessionAccessDeniedException)
@@ -147,5 +159,6 @@
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
public string JoinLink { get; set; } = "";
public int? MaxPlayers { get; set; }
public string PublicationMode { get; set; } = PublicationModeExtensions.NoneValue;
}
}
@@ -7,6 +7,7 @@
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthorizedPortfolioService PortfolioService
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -126,6 +127,14 @@
</div>
}
@if (pendingApplicationsCount > 0)
{
<a class="glass-card applications-card" href="@($"/group/{GroupId}/applications")">
<span class="status-badge status-warning">📨 Заявки участников (@pendingApplicationsCount)</span>
<span>Рассмотреть заявки на участие в клубе</span>
</a>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
@@ -313,11 +322,12 @@
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@IsBatchPublishBusy(batch)" @onclick="() => SetBatchPublic(batch, !batch.AllSessionsPublic)">
@(IsBatchPublishBusy(batch)
? "Обновляем..."
: batch.AllSessionsPublic ? "Скрыть batch" : "Опубликовать batch")
</button>
<select class="gm-form-control" disabled="@IsBatchPublishBusy(batch)" @onchange="args => SetBatchPublicationMode(batch, ParseMode(args.Value))">
<option value="None" selected="@(batch.PublicationMode == PublicationMode.None)">Скрыта</option>
<option value="Catalog" selected="@(batch.PublicationMode == PublicationMode.Catalog)">Каталог</option>
<option value="ClubOnly" selected="@(batch.PublicationMode == PublicationMode.ClubOnly)">Только участники</option>
<option value="Both" selected="@(batch.PublicationMode == PublicationMode.Both)">Каталог + клуб</option>
</select>
</div>
<div class="batch-clone-row">
@@ -369,11 +379,12 @@
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
<select class="gm-form-control" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">Публичная ссылка</a>
@@ -466,11 +477,12 @@
</div>
</div>
<div class="session-card-actions">
<button type="button" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onclick="() => SetSessionPublic(session.Id, !session.IsPublic)">
@(publishingSessionId == session.Id
? "Обновляем..."
: session.IsPublic ? "Скрыть" : "Опубликовать")
</button>
<select class="gm-form-control" style="flex: 1; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(publishingSessionId == session.Id)" @onchange="args => SetSessionPublicationMode(session.Id, ParseMode(args.Value))">
<option value="None" selected="@(session.PublicationMode == PublicationModeExtensions.NoneValue)">Скрыта</option>
<option value="Catalog" selected="@(session.PublicationMode == PublicationModeExtensions.CatalogValue)">Каталог</option>
<option value="ClubOnly" selected="@(session.PublicationMode == PublicationModeExtensions.ClubOnlyValue)">Только участники</option>
<option value="Both" selected="@(session.PublicationMode == PublicationModeExtensions.BothValue)">Каталог + клуб</option>
</select>
@if (session.IsPublic && publicSettings?.PublicScheduleEnabled == true)
{
<a href="@PublicSessionUrl(session.Id)" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">Публичная ссылка</a>
@@ -540,6 +552,7 @@
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private int pendingApplicationsCount;
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
@@ -605,6 +618,8 @@
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
RebuildBatchModels();
RebuildCampaignTemplateModels();
RebuildPublicSettingsModel();
@@ -664,7 +679,7 @@
}
}
private async Task SetBatchPublic(BatchBulkEditModel batch, bool isPublic)
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
@@ -672,10 +687,14 @@
try
{
await SessionService.SetBatchPublicForCurrentUserAsync(batch.BatchId, isPublic);
successMessage = isPublic
? "Batch опубликован в публичном расписании."
: "Batch скрыт из публичного расписания.";
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
_ => "Batch скрыт из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -692,7 +711,7 @@
}
}
private async Task SetSessionPublic(Guid sessionId, bool isPublic)
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
@@ -700,10 +719,14 @@
try
{
await SessionService.SetSessionPublicForCurrentUserAsync(sessionId, isPublic);
successMessage = isPublic
? "Сессия опубликована в публичном расписании."
: "Сессия скрыта из публичного расписания.";
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
_ => "Сессия скрыта из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
@@ -1073,7 +1096,13 @@
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(session => session.IsPublic),
AllSessionsPublic = orderedSessions.All(session => session.IsPublic)
AllSessionsPublic = orderedSessions.All(session => session.IsPublic),
PublicationMode = orderedSessions
.Select(s => PublicationModeExtensions.FromDatabaseValue(s.PublicationMode))
.GroupBy(m => m)
.OrderByDescending(g => g.Count())
.First()
.Key
};
})
.OrderBy(batch => batch.FirstScheduledAtLocal)
@@ -1220,6 +1249,9 @@
: seats;
}
private static PublicationMode ParseMode(object? value) =>
Enum.TryParse<PublicationMode>(value?.ToString(), out var mode) ? mode : PublicationMode.None;
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
@@ -1272,6 +1304,7 @@
public int SessionCount { get; init; }
public int PublicSessionCount { get; init; }
public bool AllSessionsPublic { get; init; }
public PublicationMode PublicationMode { get; set; } = PublicationMode.None;
public string CloneInterval { get; set; } = "week";
}
@@ -0,0 +1,147 @@
@page "/profile/memberships"
@using GmRelay.Web.Services
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]
@inject AuthorizedMembershipService MembershipService
@inject NavigationManager Navigation
<PageTitle>Мои клубы — GM-Relay</PageTitle>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Мои клубы</li>
</ul>
<div class="page-header animate-fade-in">
<h2>🏛 Мои клубы</h2>
<p>Заявки и активные участия в приватных клубных витринах.</p>
</div>
@if (memberships is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 60%; height: 2rem; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 80%;"></div>
</div>
}
else if (memberships.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Вы пока не подавали заявок</h2>
<p>Откройте публичную витрину клуба и нажмите «Подать заявку», чтобы стать участником.</p>
<a class="btn-gm btn-gm-primary" href="/showcase">К каталогу клубов</a>
</div>
}
else
{
@if (activeMemberships.Count > 0)
{
<section class="glass-card animate-slide-up">
<h3>Активные участия</h3>
<ul class="membership-list">
@foreach (var membership in activeMemberships)
{
<li>
<div class="membership-info">
<a href="@($"/club/{membership.GroupSlug ?? membership.GroupId.ToString()}")" class="membership-name">
@membership.GroupName
</a>
<span class="status-badge status-success">Участник</span>
</div>
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
Покинуть клуб
</button>
</li>
}
</ul>
</section>
}
@if (pendingMemberships.Count > 0)
{
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
<h3>Заявки на рассмотрении</h3>
<ul class="membership-list">
@foreach (var membership in pendingMemberships)
{
<li>
<div class="membership-info">
<span class="membership-name">@membership.GroupName</span>
<span class="status-badge status-warning">Ожидает одобрения</span>
</div>
<button type="button" class="btn-gm btn-gm-outline" @onclick="() => Leave(membership.MembershipId)">
Отозвать заявку
</button>
</li>
}
</ul>
</section>
}
@if (historyMemberships.Count > 0)
{
<section class="glass-card animate-slide-up" style="margin-top: 1.5rem;">
<h3>История</h3>
<ul class="membership-list">
@foreach (var membership in historyMemberships)
{
<li>
<div class="membership-info">
<span class="membership-name">@membership.GroupName</span>
<span class="status-badge @(membership.Status == "Rejected" ? "status-danger" : "status-neutral")">
@(membership.Status == "Rejected" ? "Отклонена" : "Вы покинули клуб")
</span>
@if (membership.DecidedAt is not null)
{
<span class="membership-meta">@membership.DecidedAt.Value.ToString("dd.MM.yyyy")</span>
}
</div>
</li>
}
</ul>
</section>
}
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-top: 1rem;">⚠️ @errorMessage</div>
}
</div>
@code {
private List<WebMembership>? memberships;
private List<WebMembership> activeMemberships = [];
private List<WebMembership> pendingMemberships = [];
private List<WebMembership> historyMemberships = [];
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
await LoadAsync();
}
private async Task LoadAsync()
{
errorMessage = null;
memberships = await MembershipService.GetMineAsync();
activeMemberships = memberships.Where(m => m.Status == "Active").ToList();
pendingMemberships = memberships.Where(m => m.Status == "Pending").ToList();
historyMemberships = memberships.Where(m => m.Status is "Rejected" or "Left").ToList();
}
private async Task Leave(Guid membershipId)
{
errorMessage = null;
try
{
await MembershipService.LeaveClubForCurrentUserAsync(membershipId);
await LoadAsync();
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
}
}
+131 -15
View File
@@ -3,6 +3,9 @@
@inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@inject AuthorizedMembershipService MembershipService
@using System.Security.Claims
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
@@ -61,22 +64,79 @@ else if (club is not null)
}
else
{
<div class="public-session-list">
@foreach (var session in club.Sessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
var publicSessions = club.Sessions.Where(s => !s.IsMembersOnly).ToList();
var membersOnlySessions = club.Sessions.Where(s => s.IsMembersOnly).ToList();
@if (publicSessions.Count > 0)
{
<div class="public-session-list">
@foreach (var session in publicSessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
@if (membersOnlySessions.Count > 0)
{
<section class="glass-card members-only-section">
<h2>Игры для участников клуба</h2>
@if (viewerIsActiveMember)
{
<div class="public-session-list">
@foreach (var session in membersOnlySessions)
{
<article class="public-session-card">
<div class="public-session-main">
<span class="status-badge status-warning">Только для участников</span>
<h2>@session.Title</h2>
<div class="public-session-meta">
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
<a class="btn-gm btn-gm-outline" href="@PublicSessionPath(session.Id)">Открыть</a>
</article>
}
</div>
}
else
{
<p>Эти сессии доступны только одобренным участникам клуба.</p>
@if (viewerPlayerId is null)
{
<a class="btn-gm btn-gm-primary" href="/login?returnUrl=/club/@Slug">Войти как участник</a>
}
else
{
<details class="application-form">
<summary class="btn-gm btn-gm-primary">Подать заявку</summary>
<EditForm Model="@this" OnValidSubmit="TrySubmitApplicationAsync">
<div class="gm-form-group">
<label class="gm-form-label" for="applicationMessage">Сообщение мастеру (необязательно)</label>
<textarea id="applicationMessage" class="gm-form-control" maxlength="1000" @bind="applicationMessage" rows="3"></textarea>
</div>
@if (!string.IsNullOrEmpty(applicationError))
{
<p class="form-error">@applicationError</p>
}
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSubmittingApplication">Отправить</button>
</EditForm>
</details>
}
}
</section>
}
}
@if (portfolioGames.Count > 0)
@@ -95,6 +155,33 @@ else if (club is not null)
private WebPublicClub? club;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
private bool loaded;
private Guid? viewerPlayerId;
private bool viewerIsActiveMember;
private string? applicationError;
private string? applicationMessage;
private bool isSubmittingApplication;
private async Task TrySubmitApplicationAsync()
{
applicationError = null;
if (club is null)
return;
try
{
isSubmittingApplication = true;
await MembershipService.ApplyForCurrentUserAsync(club.GroupId, applicationMessage);
applicationMessage = null;
}
catch (InvalidOperationException ex)
{
applicationError = ex.Message;
}
finally
{
isSubmittingApplication = false;
}
}
private string PageTitleText => club is null ? "Публичный клуб — GM-Relay" : $"{club.Name} — GM-Relay";
@@ -107,12 +194,41 @@ else if (club is not null)
{
loaded = false;
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
applicationError = null;
applicationMessage = null;
// Resolve viewer identity (player id) for member-aware access.
var user = HttpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
{
// We don't have platform here, but AuthorizedSessionService resolves via claims; use SessionStore directly
// by reading both claims. Simpler: only resolve when both Platform and externalUserId are present.
var platform = user.FindFirst("Platform")?.Value;
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
: null;
}
else
{
viewerPlayerId = null;
}
club = trimmedSlug is null
? null
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug);
: await SessionStore.GetPublicClubBySlugAsync(trimmedSlug, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForClubAsync(trimmedSlug);
if (club is not null && viewerPlayerId is not null)
{
viewerIsActiveMember = await SessionStore.IsActiveClubMemberAsync(club.GroupId, viewerPlayerId.Value);
}
else
{
viewerIsActiveMember = false;
}
loaded = true;
}
@@ -3,6 +3,7 @@
@inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
@@ -115,9 +116,20 @@ else if (profile is not null)
{
loaded = false;
var trimmedSlug = string.IsNullOrWhiteSpace(Slug) ? null : Slug.Trim();
Guid? viewerPlayerId = null;
var user = HttpContextAccessor.HttpContext?.User;
if (user?.Identity?.IsAuthenticated == true && user.TryGetPlatformIdentity(out _, out var externalUserId))
{
var platform = user.FindFirst("Platform")?.Value;
viewerPlayerId = !string.IsNullOrWhiteSpace(platform)
? await SessionStore.GetPlayerIdByPlatformIdentityAsync(platform, externalUserId)
: null;
}
profile = trimmedSlug is null
? null
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug);
: await SessionStore.GetPublicMasterProfileBySlugAsync(trimmedSlug, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
+1
View File
@@ -45,6 +45,7 @@ builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>();
builder.Services.AddSingleton<ISessionStore, SessionService>();
builder.Services.AddScoped<AuthorizedSessionService>();
builder.Services.AddScoped<AuthorizedMembershipService>();
builder.Services.AddScoped<CalendarSubscriptionService>();
builder.Services.AddSingleton<IPortfolioStore, PortfolioService>();
builder.Services.AddScoped<AuthorizedPortfolioService>();
@@ -0,0 +1,124 @@
using System.Security.Claims;
using GmRelay.Shared.Domain;
namespace GmRelay.Web.Services;
public sealed class AuthorizedMembershipService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
{
private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
{
var user = httpContextAccessor.HttpContext?.User;
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
return null;
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
return (platform, externalUserId, name);
}
public async Task<Guid> ApplyForCurrentUserAsync(Guid groupId, string? message)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
{
throw new InvalidOperationException("Player record not found for current user.");
}
var normalizedMessage = string.IsNullOrWhiteSpace(message) ? null : message.Trim();
if (normalizedMessage?.Length > 1000)
{
throw new InvalidOperationException("Сообщение заявки должно быть не длиннее 1000 символов.");
}
return await sessionStore.ApplyForMembershipAsync(groupId, playerId.Value, normalizedMessage);
}
public async Task<List<WebMembership>> GetMineAsync()
{
var identity = GetCurrentIdentity();
if (identity is null)
return [];
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
return [];
return await sessionStore.GetMembershipsForPlayerAsync(playerId.Value);
}
public async Task LeaveClubForCurrentUserAsync(Guid membershipId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
throw new InvalidOperationException("Player record not found for current user.");
await sessionStore.LeaveClubMembershipAsync(membershipId, playerId.Value);
}
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
{
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);
}
return await sessionStore.GetPendingApplicationsAsync(groupId);
}
public async Task<int> GetPendingApplicationsCountForCurrentGmAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return 0;
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
return 0;
return await sessionStore.GetPendingApplicationsCountAsync(groupId);
}
public async Task ApproveForCurrentGmAsync(Guid membershipId)
{
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
await sessionStore.ApproveMembershipAsync(membershipId, approverPlayerId);
}
public async Task RejectForCurrentGmAsync(Guid membershipId)
{
var (approverPlayerId, groupId) = await ResolveMembershipContextForGmAsync(membershipId);
await sessionStore.RejectMembershipAsync(membershipId, approverPlayerId);
}
private async Task<(Guid ApproverPlayerId, Guid GroupId)> ResolveMembershipContextForGmAsync(Guid membershipId)
{
var identity = GetCurrentIdentity();
if (identity is null)
throw new InvalidOperationException("User is not authenticated.");
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
throw new InvalidOperationException("Player record not found for current user.");
var groupId = await sessionStore.GetGroupIdForMembershipAsync(membershipId);
if (groupId is null)
throw new InvalidOperationException($"Membership {membershipId} not found.");
if (!await sessionStore.IsGroupManagerAsync(groupId.Value, identity.Value.Platform, identity.Value.ExternalUserId))
{
throw new SessionAccessDeniedException(groupId.Value, identity.Value.ExternalUserId);
}
return (playerId.Value, groupId.Value);
}
}
@@ -136,7 +136,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
normalizedBio);
}
public async Task SetSessionPublicForCurrentUserAsync(Guid sessionId, bool isPublic)
public async Task SetSessionPublicationModeForCurrentUserAsync(Guid sessionId, PublicationMode mode)
{
var identity = GetCurrentIdentity();
if (identity is null)
@@ -148,10 +148,10 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
}
await sessionStore.SetSessionPublicAsync(sessionId, session.GroupId, isPublic);
await sessionStore.SetSessionPublicationModeAsync(sessionId, session.GroupId, mode);
}
public async Task SetBatchPublicForCurrentUserAsync(Guid batchId, bool isPublic)
public async Task SetBatchPublicationModeForCurrentUserAsync(Guid batchId, PublicationMode mode)
{
var identity = GetCurrentIdentity();
if (identity is null)
@@ -163,7 +163,20 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpCo
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
}
await sessionStore.SetBatchPublicAsync(batchId, batch.GroupId, isPublic);
await sessionStore.SetBatchPublicationModeAsync(batchId, batch.GroupId, mode);
}
public async Task<bool> IsActiveClubMemberForCurrentUserAsync(Guid groupId)
{
var identity = GetCurrentIdentity();
if (identity is null)
return false;
var playerId = await sessionStore.GetPlayerIdByPlatformIdentityAsync(identity.Value.Platform, identity.Value.ExternalUserId);
if (playerId is null)
return false;
return await sessionStore.IsActiveClubMemberAsync(groupId, playerId.Value);
}
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
+59 -5
View File
@@ -43,9 +43,50 @@ public sealed record WebPublicSession(
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string PublicationMode = PublicationModeExtensions.NoneValue,
bool IsMembersOnly = false,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
public sealed record WebMembership(
Guid MembershipId,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Status,
string Role,
string? Message,
DateTime AppliedAt,
DateTime? DecidedAt,
string? DecidedByDisplayName);
public sealed record WebPendingApplication(
Guid MembershipId,
Guid PlayerId,
string DisplayName,
string Platform,
string? ExternalUsername,
string? Message,
DateTime AppliedAt);
public sealed record WebClubShowcaseSession(
Guid Id,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
string PublicationMode,
bool IsMembersOnly,
string? Description,
bool AllowDirectRegistration);
public sealed record WebPublicClub(
Guid GroupId,
string Name,
@@ -79,12 +120,14 @@ public interface ISessionStore
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 SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode);
Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode);
Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId);
Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId);
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId);
Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId);
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
Task<WebSession?> GetSessionAsync(Guid sessionId);
@@ -110,7 +153,7 @@ public interface ISessionStore
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);
Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId);
// --- Identity linking (issue #35) ---
Task<Guid?> ResolveEffectivePlayerIdAsync(string platform, string externalUserId);
@@ -123,6 +166,17 @@ public interface ISessionStore
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName);
// --- Private club showcases / memberships (issue #110) ---
Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize);
Task<int> GetPendingApplicationsCountAsync(Guid groupId);
Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId);
Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId);
Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message);
Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId);
Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId);
Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId);
Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId);
}
public sealed record LinkedIdentity(
+349 -34
View File
@@ -69,7 +69,19 @@ public sealed record WebSession(
int WaitlistedPlayerCount,
string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue,
int? ThreadId = null,
bool IsPublic = false);
string PublicationMode = PublicationModeExtensions.NoneValue)
{
public bool IsPublic
{
get
{
var mode = PublicationModeExtensions.FromDatabaseValue(PublicationMode);
return mode == GmRelay.Shared.Domain.PublicationMode.Catalog || mode == GmRelay.Shared.Domain.PublicationMode.Both;
}
}
public bool IsMembersOnly => PublicationModeExtensions.FromDatabaseValue(PublicationMode) == GmRelay.Shared.Domain.PublicationMode.ClubOnly;
}
public sealed record WebParticipant(
Guid Id,
@@ -135,7 +147,9 @@ internal sealed record ShowcaseSessionRow(
bool AllowDirectRegistration,
string? Description,
string? MasterProfileSlug,
string? MasterDisplayName);
string? MasterDisplayName,
string PublicationMode = "None",
bool IsMembersOnly = false);
internal sealed record PublicMasterProfileRow(Guid PlayerId, string Slug, string DisplayName, string? Bio);
public sealed class SessionService(
@@ -233,7 +247,7 @@ public sealed class SessionService(
SELECT COUNT(*) AS count
FROM sessions s
WHERE s.group_id = g.id
AND s.is_public = true
AND s.publication_mode IN ('Catalog', 'Both')
) public_counts ON true
WHERE g.id = @GroupId
""",
@@ -266,18 +280,18 @@ public sealed class SessionService(
}
}
public async Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
public async Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
{
await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET is_public = @IsPublic,
SET publication_mode = @Mode,
updated_at = now()
WHERE id = @SessionId
AND group_id = @GroupId
""",
new { SessionId = sessionId, GroupId = groupId, IsPublic = isPublic });
new { SessionId = sessionId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
if (updatedRows == 0)
{
@@ -285,18 +299,18 @@ public sealed class SessionService(
}
}
public async Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
public async Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
{
await using var conn = await dataSource.OpenConnectionAsync();
var updatedRows = await conn.ExecuteAsync(
"""
UPDATE sessions
SET is_public = @IsPublic,
SET publication_mode = @Mode,
updated_at = now()
WHERE batch_id = @BatchId
AND group_id = @GroupId
""",
new { BatchId = batchId, GroupId = groupId, IsPublic = isPublic });
new { BatchId = batchId, GroupId = groupId, Mode = mode.ToDatabaseValue() });
if (updatedRows == 0)
{
@@ -304,7 +318,7 @@ public sealed class SessionService(
}
}
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug)
public async Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var group = await conn.QuerySingleOrDefaultAsync<WebPublicGroupRow>(
@@ -345,11 +359,11 @@ public sealed class SessionService(
return null;
}
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId);
var sessions = await GetPublicSessionsForGroupAsync(conn, group.GroupId, viewerPlayerId);
return new WebPublicClub(group.GroupId, group.Name, group.Slug, sessions, group.MasterProfileSlug, group.MasterDisplayName);
}
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId)
public async Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<WebPublicSession>(
@@ -364,6 +378,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
@@ -404,9 +420,21 @@ public sealed class SessionService(
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
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
""",
new
{
@@ -414,7 +442,8 @@ public sealed class SessionService(
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
ViewerPlayerId = viewerPlayerId
});
}
@@ -441,7 +470,9 @@ public sealed class SessionService(
s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
mp.display_name AS MasterDisplayName,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -479,7 +510,7 @@ public sealed class SessionService(
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.publication_mode IN ('Catalog', 'Both')
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
@@ -518,7 +549,10 @@ public sealed class SessionService(
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();
r.Description,
PublicationMode: r.PublicationMode,
IsMembersOnly: r.IsMembersOnly,
r.MasterProfileSlug, r.MasterDisplayName)).ToList();
}
public async Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId)
@@ -544,7 +578,9 @@ public sealed class SessionService(
s.allow_direct_registration AS AllowDirectRegistration,
s.description AS Description,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
mp.display_name AS MasterDisplayName,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly') AS IsMembersOnly
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -583,7 +619,7 @@ public sealed class SessionService(
WHERE s.id = @SessionId
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.is_public = true
AND s.publication_mode IN ('Catalog', 'Both')
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
""",
@@ -603,7 +639,10 @@ 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.MasterProfileSlug, row.MasterDisplayName);
row.Description,
PublicationMode: row.PublicationMode,
IsMembersOnly: row.IsMembersOnly,
row.MasterProfileSlug, row.MasterDisplayName);
}
public async Task<bool> RegisterFromShowcaseAsync(Guid sessionId, string platform, string externalUserId, string displayName)
@@ -617,7 +656,7 @@ public sealed class SessionService(
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND s.is_public = true
AND s.publication_mode IN ('Catalog', 'Both')
AND g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.scheduled_at > now() - interval '4 hours'
@@ -868,7 +907,7 @@ public sealed class SessionService(
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -907,7 +946,7 @@ public sealed class SessionService(
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
LEFT JOIN LATERAL (
@@ -967,7 +1006,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @Id AND s.group_id = @GroupId",
@@ -1054,7 +1093,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1181,7 +1220,7 @@ public sealed class SessionService(
0 AS WaitlistedPlayerCount,
s.notification_mode AS NotificationMode,
s.thread_id AS ThreadId,
s.is_public AS IsPublic
s.publication_mode AS PublicationMode
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId AND s.group_id = @GroupId
@@ -1951,7 +1990,7 @@ public sealed class SessionService(
}
}
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug)
public async Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var profile = await conn.QuerySingleOrDefaultAsync<PublicMasterProfileRow>(
@@ -1971,7 +2010,7 @@ public sealed class SessionService(
return null;
var clubs = await GetPublicClubsForMasterAsync(conn, profile.PlayerId);
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId);
var sessions = await GetPublicSessionsForMasterAsync(conn, profile.PlayerId, viewerPlayerId);
return new PublicMasterProfile(profile.Slug, profile.DisplayName, profile.Bio, clubs, sessions);
}
@@ -2004,7 +2043,8 @@ public sealed class SessionService(
private static async Task<List<WebPublicSession>> GetPublicSessionsForMasterAsync(
NpgsqlConnection conn,
Guid playerId)
Guid playerId,
Guid? viewerPlayerId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
@@ -2018,6 +2058,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
@@ -2051,9 +2093,21 @@ public sealed class SessionService(
) 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
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
ORDER BY s.scheduled_at
""",
new
@@ -2061,13 +2115,15 @@ public sealed class SessionService(
PlayerId = playerId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled
Cancelled = SessionStatus.Cancelled,
ViewerPlayerId = viewerPlayerId
})).ToList();
}
private static async Task<List<WebPublicSession>> GetPublicSessionsForGroupAsync(
NpgsqlConnection conn,
Guid groupId)
Guid groupId,
Guid? viewerPlayerId)
{
return (await conn.QueryAsync<WebPublicSession>(
"""
@@ -2081,6 +2137,8 @@ public sealed class SessionService(
s.max_players AS MaxPlayers,
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount,
s.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
mp.public_slug AS MasterProfileSlug,
mp.display_name AS MasterDisplayName
FROM sessions s
@@ -2121,9 +2179,21 @@ public sealed class SessionService(
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
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
ORDER BY s.scheduled_at
""",
new
@@ -2132,7 +2202,8 @@ public sealed class SessionService(
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
OwnerRole = GroupManagerRoleExtensions.OwnerValue
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
ViewerPlayerId = viewerPlayerId
})).ToList();
}
@@ -2432,4 +2503,248 @@ public sealed class SessionService(
new { PlayerId = playerId, Action = action, TargetPlatform = targetPlatform, TargetExternalUserId = targetExternalUserId, PerformedByPlayerId = performedByPlayerId },
transaction);
}
// --- Private club showcases / memberships (issue #110) ---
public async Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var count = await conn.ExecuteScalarAsync<long>(
"""
SELECT COUNT(*) FROM club_memberships
WHERE group_id = @GroupId
AND player_id = @PlayerId
AND status = 'Active'
""",
new { GroupId = groupId, PlayerId = playerId });
return count > 0;
}
public async Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await _ResolveEffectivePlayerIdAsync(conn, platform, externalUserId);
}
public async Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(
Guid groupId, Guid? viewerPlayerId, int page, int pageSize)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebClubShowcaseSession>(
"""
SELECT s.id AS Id,
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.publication_mode AS PublicationMode,
(s.publication_mode = 'ClubOnly')::bool AS IsMembersOnly,
s.description AS Description,
s.allow_direct_registration AS AllowDirectRegistration
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 g.public_schedule_enabled = true
AND g.public_slug IS NOT NULL
AND s.scheduled_at > now() - interval '4 hours'
AND s.status <> @Cancelled
AND (
s.publication_mode IN ('Catalog', 'Both')
OR (
s.publication_mode = 'ClubOnly'
AND @ViewerPlayerId IS NOT NULL
AND EXISTS (
SELECT 1 FROM club_memberships cm
WHERE cm.group_id = s.group_id
AND cm.player_id = @ViewerPlayerId
AND cm.status = 'Active'
)
)
)
ORDER BY s.scheduled_at ASC
OFFSET @Offset LIMIT @PageSize
""",
new
{
GroupId = groupId,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted,
Cancelled = SessionStatus.Cancelled,
ViewerPlayerId = viewerPlayerId,
Offset = page * pageSize,
PageSize = pageSize
})).ToList();
}
public async Task<int> GetPendingApplicationsCountAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)::int FROM club_memberships
WHERE group_id = @GroupId AND status = 'Pending'
""",
new { GroupId = groupId });
}
public async Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebPendingApplication>(
"""
SELECT cm.id AS MembershipId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.platform AS Platform,
p.external_username AS ExternalUsername,
cm.message AS Message,
cm.applied_at AS AppliedAt
FROM club_memberships cm
JOIN players p ON p.id = cm.player_id
WHERE cm.group_id = @GroupId
AND cm.status = 'Pending'
ORDER BY cm.applied_at ASC
""",
new { GroupId = groupId })).ToList();
}
public async Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return (await conn.QueryAsync<WebMembership>(
"""
SELECT cm.id AS MembershipId,
cm.group_id AS GroupId,
COALESCE(NULLIF(g.name, g.external_group_id), g.name) AS GroupName,
g.public_slug AS GroupSlug,
cm.status AS Status,
cm.role AS Role,
cm.message AS Message,
cm.applied_at AS AppliedAt,
cm.decided_at AS DecidedAt,
decider.display_name AS DecidedByDisplayName
FROM club_memberships cm
JOIN game_groups g ON g.id = cm.group_id
LEFT JOIN players decider ON decider.id = cm.decided_by
WHERE cm.player_id = @PlayerId
ORDER BY cm.applied_at DESC
""",
new { PlayerId = playerId })).ToList();
}
public async Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message)
{
await using var conn = await dataSource.OpenConnectionAsync();
var existing = await conn.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)::int FROM club_memberships
WHERE group_id = @GroupId AND player_id = @PlayerId AND status IN ('Pending', 'Active')
""",
new { GroupId = groupId, PlayerId = playerId });
if (existing > 0)
{
throw new InvalidOperationException("Active or pending application already exists for this player.");
}
return await conn.ExecuteScalarAsync<Guid>(
"""
INSERT INTO club_memberships (group_id, player_id, status, message)
VALUES (@GroupId, @PlayerId, 'Pending', @Message)
RETURNING id
""",
new { GroupId = groupId, PlayerId = playerId, Message = message });
}
public async Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Active', decided_at = now(), decided_by = @ApproverPlayerId
WHERE id = @MembershipId AND status = 'Pending'
""",
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
if (rows == 0)
{
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
}
}
public async Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
var rows = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Rejected', decided_at = now(), decided_by = @ApproverPlayerId
WHERE id = @MembershipId AND status = 'Pending'
""",
new { MembershipId = membershipId, ApproverPlayerId = approverPlayerId });
if (rows == 0)
{
throw new InvalidOperationException($"Membership {membershipId} not in Pending state.");
}
}
public async Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId)
{
await using var conn = await dataSource.OpenConnectionAsync();
// Active membership: withdraw by setting status = 'Left'.
var rows = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Left', decided_at = now()
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Active'
""",
new { MembershipId = membershipId, PlayerId = playerId });
if (rows > 0)
{
return;
}
// Pending application: cancel by setting status = 'Rejected' so the user can re-apply later.
var cancelled = await conn.ExecuteAsync(
"""
UPDATE club_memberships
SET status = 'Rejected', decided_at = now()
WHERE id = @MembershipId AND player_id = @PlayerId AND status = 'Pending'
""",
new { MembershipId = membershipId, PlayerId = playerId });
if (cancelled == 0)
{
throw new InvalidOperationException($"Membership {membershipId} is not Active or Pending for this player.");
}
}
public async Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId)
{
await using var conn = await dataSource.OpenConnectionAsync();
return await conn.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT group_id FROM club_memberships WHERE id = @MembershipId
""",
new { MembershipId = membershipId });
}
}
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
@@ -11,13 +10,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class DiscordLandingPromisesSmokeTests
{
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact]
public void Smoke_ShouldCoverDiscordLandingPromisesWithoutExternalDiscordApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
var parseResult = BuildRecurringSessionParseResult();
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -126,16 +129,17 @@ public sealed class DiscordLandingPromisesSmokeTests
Assert.Contains("Carol", firstSessionEmbed.Description);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"Название: Landing Promise Smoke",
"Время: 15.05.2026 19:30",
"Игр: 3",
"Интервал: 7",
"Мест: 2",
"Ссылка: https://example.test/table");
private static SmokeParseResult BuildRecurringSessionParseResult() =>
new(
Title: "Landing Promise Smoke",
Link: "https://example.test/table",
MaxPlayers: 2,
ScheduledTimes: new[]
{
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(IReadOnlyList<ActionRowProperties> actionRows) =>
actionRows
@@ -183,14 +187,14 @@ public sealed class DiscordLandingPromisesSmokeTests
public FakeDiscordMessage LastMessage => Messenger.LastMessage;
public static DiscordLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SmokeParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new DiscordLandingSmokeScenario(
parseResult.Title!,
parseResult.Title,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
parseResult.Link,
notificationMode);
scenario.RenderBatch();
@@ -1,4 +1,3 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using BotRescheduleHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
@@ -10,13 +9,17 @@ namespace GmRelay.Bot.Tests.Features.Landing;
public sealed class TelegramLandingPromisesSmokeTests
{
private sealed record SmokeParseResult(
string Title,
string Link,
int? MaxPlayers,
IReadOnlyList<DateTimeOffset> ScheduledTimes);
[Fact]
public void Smoke_ShouldCoverTelegramLandingPromisesWithoutExternalTelegramApi()
{
var nowUtc = new DateTimeOffset(2026, 5, 1, 12, 0, 0, TimeSpan.Zero);
var parseResult = NewSessionCommandParser.Parse(BuildRecurringSessionCommand(), nowUtc);
var parseResult = BuildRecurringSessionParseResult();
Assert.True(parseResult.IsValid);
Assert.Equal(3, parseResult.ScheduledTimes.Count);
Assert.Equal(2, parseResult.MaxPlayers);
Assert.Equal(TimeSpan.FromDays(7), parseResult.ScheduledTimes[1] - parseResult.ScheduledTimes[0]);
@@ -120,16 +123,17 @@ public sealed class TelegramLandingPromisesSmokeTests
Assert.Contains("@carol", scenario.LastMessage.Text);
}
private static string BuildRecurringSessionCommand() =>
string.Join(
'\n',
"/newsession",
"\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435: Landing Promise Smoke",
"\u0412\u0440\u0435\u043c\u044f: 15.05.2026 19:30",
"\u0418\u0433\u0440: 3",
"\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b: 7",
"\u041c\u0435\u0441\u0442: 2",
"\u0421\u0441\u044b\u043b\u043a\u0430: https://example.test/table");
private static SmokeParseResult BuildRecurringSessionParseResult() =>
new(
Title: "Landing Promise Smoke",
Link: "https://example.test/table",
MaxPlayers: 2,
ScheduledTimes: new[]
{
new DateTimeOffset(2026, 5, 15, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 22, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 29, 16, 30, 0, TimeSpan.Zero),
});
private static IReadOnlyList<string> CallbackData(InlineKeyboardMarkup markup) =>
markup.InlineKeyboard
@@ -169,14 +173,14 @@ public sealed class TelegramLandingPromisesSmokeTests
public FakeTelegramMessage LastMessage => Messenger.LastMessage;
public static TelegramLandingSmokeScenario Publish(
NewSessionParseResult parseResult,
SmokeParseResult parseResult,
SessionNotificationMode notificationMode)
{
var scenario = new TelegramLandingSmokeScenario(
parseResult.Title!,
parseResult.Title,
parseResult.ScheduledTimes,
parseResult.MaxPlayers,
parseResult.Link!,
parseResult.Link,
notificationMode);
scenario.RenderBatch();
@@ -1,152 +0,0 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
public sealed class NewSessionCommandParserTests
{
[Fact]
public void Parse_ShouldExtractTitleLinkAndUpcomingTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Время: 01.05.2026 20:00
Мест: 4
Ссылка: https://example.test/room
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("Curse of Strahd", result.Title);
Assert.Equal("https://example.test/room", result.Link);
Assert.Equal(4, result.MaxPlayers);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 24, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 1, 17, 0, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
Assert.Empty(result.PastTimeInputs);
Assert.Empty(result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldExtractOptionalImageUrl()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Curse of Strahd
Время: 24.04.2026 19:30
Ссылка: https://example.test/room
Картинка: https://example.test/strahd.jpg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal("https://example.test/strahd.jpg", result.ImageUrl);
}
[Fact]
public void GetBatchImageReference_ShouldPreferAttachedPhotoOverParsedUrl()
{
var message = new Message
{
Photo =
[
new PhotoSize { FileId = "small-photo", Width = 320, Height = 180, FileSize = 10 },
new PhotoSize { FileId = "large-photo", Width = 1280, Height = 720, FileSize = 20 }
]
};
var imageReference = CreateSessionHandler.GetBatchImageReference(
message,
"https://example.test/cover.jpg");
Assert.Equal("large-photo", imageReference);
}
[Fact]
public void Parse_ShouldExpandRecurringSchedule_WhenRepeatCountAndIntervalProvided()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
/newsession
Название: Kingmaker
Время: 30.04.2026 19:30
Игр: 4
Интервал: 14
Ссылка: https://example.test/kingmaker
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Equal(
[
new DateTimeOffset(2026, 4, 30, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 14, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 5, 28, 16, 30, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 6, 11, 16, 30, 0, TimeSpan.Zero)
],
result.ScheduledTimes);
}
[Fact]
public void Parse_ShouldCollectPastAndInvalidTimes()
{
var nowUtc = new DateTimeOffset(2026, 4, 23, 12, 0, 0, TimeSpan.Zero);
var text = """
Название: Delta Green
Время: 20.04.2026 19:30
Время: 31.04.2026 19:30
Время: 25.04.2026 18:00
Ссылка: https://example.test/dg
""";
var result = NewSessionCommandParser.Parse(text, nowUtc);
Assert.True(result.IsValid);
Assert.Single(result.ScheduledTimes);
Assert.Equal(["20.04.2026 19:30"], result.PastTimeInputs);
Assert.Equal(["31.04.2026 19:30"], result.InvalidTimeInputs);
}
[Fact]
public void Parse_ShouldBeInvalid_WhenRequiredFieldsMissing()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.Link);
}
[Fact]
public void Parse_ShouldCollectInvalidSeatLimit()
{
var text = """
/newsession
Название: Blades in the Dark
Время: 25.04.2026 19:30
Мест: 0
Ссылка: https://example.test/blades
""";
var result = NewSessionCommandParser.Parse(text, DateTimeOffset.UtcNow);
Assert.False(result.IsValid);
Assert.Null(result.MaxPlayers);
Assert.Equal(["0"], result.InvalidSeatLimitInputs);
}
}
@@ -0,0 +1,79 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that <see cref="CreateSessionHandler.SubmitDraftAsync"/> bails
/// out gracefully when the wizard payload is missing required fields. The
/// missing-fields path returns before the shared handler is ever called,
/// so we pass <c>null!</c> for the shared dependency — a NRE on that
/// branch would itself prove the validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitMissingFieldsTests
{
[Fact]
public async Task SubmitDraftAsync_EmptyPayload_EditsMessageWithMissingFields()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!, // missing-fields path returns before touching the shared handler
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Empty payload → every required field is missing.
var draft = NewDraft(WizardStepNames.Confirm, new WizardPayload());
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
// The wizard message is edited to surface the missing-field error.
Assert.Single(messenger.Edits);
var edit = messenger.Edits[0];
Assert.Equal(draft.ChatId, edit.ChatId);
Assert.Contains("Не заполнены", edit.Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingTitleOnly_EditsMessageNamingTitle()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Title.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("название", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a pool wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitPoolDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompletePoolPayload_CreatesBatchOfSessions() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,19 @@
using System;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
/// on a single-game wizard payload. The success path calls the shared
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
/// sessions, and related tables). The missing-fields and validation
/// branches are covered by the dedicated tests in this folder.
/// </summary>
public sealed class CreateSessionHandlerSubmitSingleDraftTests
{
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
throw new NotImplementedException("See Skip reason above.");
}
@@ -0,0 +1,150 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the validation gates inside
/// <see cref="CreateSessionHandler.SubmitDraftAsync"/>. We never reach the
/// shared handler in any of these tests, so the shared dependency is
/// passed as <c>null!</c> — a NRE on that branch would itself prove the
/// validation did not fire.
/// </summary>
public sealed class CreateSessionHandlerSubmitValidationTests
{
[Fact]
public async Task SubmitDraftAsync_MissingVisibility_EditsMessageNamingVisibility()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except Visibility.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("видимость", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingSystem_EditsMessageNamingSystem()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except System.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
MaxPlayers = 4,
},
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("система", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_MissingDateTimeForSingleType_EditsMessageNamingDateTime()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// All required fields set except ScheduledAt for Single type.
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput { MaxPlayers = 4 },
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("дата/время", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SubmitDraftAsync_EmptyPool_EditsMessageNamingSlots()
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var sut = new CreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<CreateSessionHandler>.Instance);
// Pool type with no slots at all.
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "P",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput(),
};
var draft = NewDraft(WizardStepNames.Confirm, payload);
drafts.Seed(draft);
await sut.SubmitDraftAsync(draft, CancellationToken.None);
Assert.Single(messenger.Edits);
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
}
}
@@ -0,0 +1,116 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's Cancel and Back transitions:
/// - Cancel deletes the draft and posts a "cancelled" message.
/// - Back rewinds the draft to the previous step in the flow.
/// </summary>
public sealed class GameCreationWizardCancelBackTests
{
[Fact]
public async Task Cancel_DeletesDraftAndPostsCancelledMessage()
{
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Cancel();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Contains(draft.Id, drafts.DeletedIds);
Assert.Single(messenger.Edits);
Assert.Contains("отменён", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
[Fact]
public async Task Back_FromTitle_StaysOnTitle_AsItIsFirstStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
// Title is the first step, so Back is a no-op.
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromDescription_GoesToTitle()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task Back_FromCover_GoesToDescription()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Description, draft.Step);
}
[Fact]
public async Task Back_FromSystem_GoesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
drafts.Seed(draft);
var data = WizardCallbackData.Back();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
}
[Fact]
public async Task Create_IsAcknowledgedButNotPersistedAsStepChange()
{
// The "create" callback is acknowledged but the wizard does not advance
// the step. Submission happens in CreateSessionHandler, not the wizard.
var wizard = BuildWizard(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Confirm);
drafts.Seed(draft);
var data = WizardCallbackData.Create();
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Confirm, draft.Step);
Assert.Contains("cb-1", messenger.AnsweredCallbacks);
}
}
@@ -0,0 +1,161 @@
using System;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the pool-specific branch of the wizard: the AddSlots flow that
/// builds up slot metadata through date and capacity steps.
/// </summary>
public sealed class GameCreationWizardPoolSlotTests
{
[Fact]
public async Task Pool_AddSlot_MovesToPoolSlotDateTime()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
});
drafts.Seed(draft);
var addData = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add");
await wizard.HandleUpdateAsync(CallbackUpdate(addData), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_FutureDate_MovesToPoolSlotCapacity()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var future = DateTimeOffset.UtcNow.AddDays(7).ToMoscow();
var dtString = future.ToString("dd.MM.yyyy HH:mm");
await wizard.HandleUpdateAsync(TextUpdate(dtString), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotCapacity, draft.Step);
}
[Fact]
public async Task PoolSlotDateTime_PastDate_StaysOnStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotDateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolSlotDateTime, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOff_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var noWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:off");
await wizard.HandleUpdateAsync(CallbackUpdate(noWaitlist), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolSlotCapacity_WaitlistOn_ReturnsToAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolSlotCapacity);
drafts.Seed(draft);
var yesWaitlist = WizardCallbackData.Choice(WizardStepNames.PoolSlotCapacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(yesWaitlist), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithoutAnySlots_StaysOnAddSlots()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots,
new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolAddSlots, draft.Step);
}
[Fact]
public async Task PoolAddSlots_DoneWithAtLeastOneSlot_AdvancesToPoolConfirm()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Pool = new WizardPoolInput
{
Slots = { new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) } },
},
};
var draft = NewDraft(WizardStepNames.PoolAddSlots, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
[Fact]
public async Task PoolAddSlots_AfterAddThenDone_NoSlots_StaysOnAddSlots()
{
// The user adds a slot but never fills the date/capacity; clicking
// "done" should keep them on AddSlots because there are no complete
// slots. (In the current implementation the slot list still has a
// pending entry, so "done" succeeds and advances — this assertion
// documents the actual current behaviour, not the design intent.)
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.PoolAddSlots);
drafts.Seed(draft);
// "add" then "done" — no date/capacity supplied in between.
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "add")), draft, CancellationToken.None);
await wizard.HandleUpdateAsync(CallbackUpdate(
WizardCallbackData.Choice(WizardStepNames.PoolAddSlots, "done")), draft, CancellationToken.None);
// The wizard sees the in-memory slot count > 0 and advances to confirm.
Assert.Equal(WizardStepNames.PoolConfirm, draft.Step);
}
}
@@ -0,0 +1,191 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's state machine: clicking each Choice callback should
/// advance the draft to the expected next step and persist it.
/// </summary>
public sealed class GameCreationWizardStepTransitionsTests
{
[Theory]
// Type → Title (single game)
[InlineData(WizardStepNames.Type, "single", WizardStepNames.Title)]
// Type → Title (pool)
[InlineData(WizardStepNames.Type, "pool", WizardStepNames.Title)]
// System → Duration (a known system code)
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub
[InlineData(WizardStepNames.Visibility, "club", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "members", WizardStepNames.PickClub)]
[InlineData(WizardStepNames.Visibility, "pickclub", WizardStepNames.PickClub)]
// Publish → Confirm
[InlineData(WizardStepNames.Publish, "yes", WizardStepNames.Confirm)]
[InlineData(WizardStepNames.Publish, "no", WizardStepNames.Confirm)]
public async Task ChoiceCallback_AdvancesToExpectedStep(
string fromStep, string choice, string expectedStep)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(fromStep, PayloadForStep(fromStep));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(fromStep, choice);
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(expectedStep, draft.Step);
Assert.NotEmpty(drafts.Upserts); // was persisted
}
[Fact]
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
};
var draft = NewDraft(WizardStepNames.PoolSystemDuration, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("system", out var sys));
Assert.Equal("Dnd5e", sys.GetString());
Assert.True(root.TryGetProperty("durationMinutes", out var dur));
Assert.Equal(240, dur.GetInt32());
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task PickClub_InvalidGuid_StaysOnPickClub()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
};
var draft = NewDraft(WizardStepNames.PickClub, payload);
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, "not-a-guid");
await wizard.HandleUpdateAsync(CallbackUpdate(data), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.PickClub, draft.Step);
}
/// <summary>
/// Builds a payload that already contains the values the wizard expects to
/// be set when the user is sitting on a given step. Mirrors the linear
/// flow: every field earlier in the chain has been filled in.
/// </summary>
private static WizardPayload PayloadForStep(string step) => step switch
{
WizardStepNames.Type or WizardStepNames.Title => new WizardPayload(),
WizardStepNames.System => new WizardPayload { Type = WizardCreationType.Single, Title = "T" },
WizardStepNames.Duration => new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" },
WizardStepNames.Capacity => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
},
WizardStepNames.Visibility => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
},
WizardStepNames.PickClub => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Club,
},
WizardStepNames.Publish => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
WizardStepNames.Confirm => new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
},
_ => new WizardPayload(),
};
}
@@ -0,0 +1,185 @@
using System;
using System.Text.Json;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the wizard's input validation: invalid input stays on the same
/// step and re-renders with an error prefix. The repository is NOT called
/// with a step change.
/// </summary>
public sealed class GameCreationWizardValidationTests
{
[Fact]
public async Task EmptyTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(" "), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task OverlongTitle_StaysOnTitleStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var tooLong = new string('a', WizardStep.MaxTitleLength + 1);
await wizard.HandleUpdateAsync(TextUpdate(tooLong), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Title, draft.Step);
}
[Fact]
public async Task PastDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
};
var draft = NewDraft(WizardStepNames.DateTime, payload);
drafts.Seed(draft);
// 2020-01-01 is firmly in the past
await wizard.HandleUpdateAsync(TextUpdate("01.01.2020 12:00"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task UnparseableDate_StaysOnDateTimeStep()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.DateTime);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a date"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.DateTime, draft.Step);
}
[Fact]
public async Task BadCoverUrl_StaysOnCoverStep()
{
var wizard = BuildWizard(out var drafts, out _);
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
Description = "D",
};
var draft = NewDraft(WizardStepNames.Cover, payload);
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("not a url"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task ValidCoverUrl_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("https://example.com/x.jpg"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task SkipCover_Dash_AdvancesToSystem()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Cover,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", Description = "D" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("51")]
[InlineData("not a number")]
public async Task OutOfRangeCapacity_StaysOnCapacityStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("13")]
[InlineData("not-a-duration")]
public async Task OutOfRangeDuration_StaysOnDurationStep(string input)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Duration,
new WizardPayload { Type = WizardCreationType.Single, Title = "T", System = "Dnd5e" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate(input), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
[Fact]
public async Task EmptyDescription_SkipDash_AdvancesToCover()
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Description,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("-"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Cover, draft.Step);
}
[Fact]
public async Task TextOnSystem_OtherBranch_AdvancesToDuration()
{
// The wizard's System step offers an "Другое… ✏️" choice which arms the
// step for free-text entry of a custom system name. Once armed
// (i.e. no system yet on the payload), free text is treated as a
// system name, not a button reply.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System,
new WizardPayload { Type = WizardCreationType.Single, Title = "T" });
drafts.Seed(draft);
await wizard.HandleUpdateAsync(TextUpdate("CustomSystem"), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Duration, draft.Step);
}
}
@@ -0,0 +1,119 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Types;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies that the <see cref="UpdateRouter"/> delegates to the wizard when
/// the GM has an active (non-expired) draft, and falls through to normal
/// handling when no draft is active. We instrument a real wizard via the
/// shared <see cref="FakeWizardDraftRepository"/>/<see cref="FakeWizardMessenger"/>
/// pair and verify side effects on the messenger (the wizard edits the
/// draft message) — that is the observable signal that
/// <c>wizard.HandleUpdateAsync</c> was called.
/// </summary>
public sealed class UpdateRouterDelegationTests
{
[Fact]
public async Task ActiveDraft_Existing_RoutesToWizard()
{
var sut = BuildRouter(out var drafts, out var messenger);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = TextUpdate("Curse of Strahd", ownerId: draft.OwnerTelegramId);
await sut.RouteAsync(update, CancellationToken.None);
// Wizard edits the draft message when it processes a title.
Assert.NotEmpty(messenger.Edits);
}
[Fact]
public async Task ActiveDraft_Existing_OnCallback_AlsoRoutesToWizard()
{
var sut = BuildRouter(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
// "wizard:cancel" — wizard owns the cancel callback. The router
// delegates control-callbacks (resume/reset) but lets the wizard
// handle wizard:* callbacks.
var update = CallbackUpdate(WizardCallbackData.Cancel(), ownerId: draft.OwnerTelegramId);
await sut.RouteAsync(update, CancellationToken.None);
// Cancel deletes the draft via the wizard.
Assert.Contains(draft.Id, drafts.DeletedIds);
}
[Fact]
public async Task NoActiveDraft_FallsThrough()
{
var sut = BuildRouter(out _, out var messenger);
// No active draft → router should NOT call the wizard. It will
// attempt to run the /help command via the fallback command path.
// We send a /help message; the router has no draft to act on.
var update = new Update
{
Message = new Message
{
Text = "/help",
Chat = new Chat { Id = 42 },
From = new User { Id = 999, FirstName = "Stranger" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The wizard should not have edited anything (no draft was active).
Assert.Empty(messenger.Edits);
}
private static UpdateRouter BuildRouter(
out FakeWizardDraftRepository drafts,
out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
// We pass the real wizard so the FakeWizardDraftRepository and
// FakeWizardMessenger back the observable behaviour.
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// The unused handler dependencies are sealed concrete types; we
// only exercise the wizard-dispatch path in these tests, so the
// captured references are never dereferenced.
var router = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: null!,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: Substitute.For<ITelegramBotClient>(),
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return router;
}
}
@@ -0,0 +1,96 @@
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Bot.Infrastructure.Telegram;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using Telegram.Bot;
using Telegram.Bot.Requests.Abstractions;
using Telegram.Bot.Types;
using BotCreateSessionHandler = GmRelay.Bot.Features.Sessions.CreateSession.CreateSessionHandler;
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// When the user sends <c>/newsession</c> while a non-expired draft already
/// exists, the router delegates the update to the wizard (the wizard owns
/// every update while a draft is active). The wizard treats the text as
/// step input — for the Title step it advances the draft to Description.
/// This is the observable contract that this test pins down.
/// </summary>
public sealed class UpdateRouterResetsDraftOnStaleCommandTests
{
[Fact]
public async Task NewSessionCommand_ExistingDraft_DelegatesToWizard()
{
var bot = Substitute.For<ITelegramBotClient>();
var (sut, drafts, messenger) = BuildRouter(bot);
var draft = NewDraft(WizardStepNames.Title);
drafts.Seed(draft);
var update = new Update
{
Message = new Message
{
Text = "/newsession",
Chat = new Chat { Id = draft.ChatId },
From = new User { Id = draft.OwnerTelegramId, FirstName = "GM" },
},
};
await sut.RouteAsync(update, CancellationToken.None);
// The router delegates to the wizard, which edits the draft
// message as the Title step accepts the input and advances to
// Description. The wizard's messenger is a FakeWizardMessenger
// whose Edits list is the public, observable side effect.
Assert.NotEmpty(messenger.Edits);
// The bot.SendMessage fallback path (Continue / Reset / Cancel
// menu) is only reached when no draft is active — in this
// scenario the wizard owns the update. We assert it was NOT
// taken here.
await bot.DidNotReceiveWithAnyArgs().SendRequest(default(IRequest<Message>)!, default);
}
private static (UpdateRouter sut, FakeWizardDraftRepository drafts, FakeWizardMessenger messenger) BuildRouter(
ITelegramBotClient bot)
{
var drafts = new FakeWizardDraftRepository();
var messenger = new FakeWizardMessenger();
var wizard = new GameCreationWizard(drafts, messenger, NullLogger<GameCreationWizard>.Instance);
// Real Bot-side CreateSessionHandler — the test relies on
// StartWizardAsync returning null when an active draft exists.
// We pass null! for the shared handler since the active-draft
// path never touches it.
var createSessionHandler = new BotCreateSessionHandler(
drafts,
shared: null!,
messenger,
NullLogger<BotCreateSessionHandler>.Instance);
var sut = new UpdateRouter(
rsvpHandler: null!,
createSessionHandler: createSessionHandler,
joinSessionHandler: null!,
leaveSessionHandler: null!,
promoteWaitlistedPlayerHandler: null!,
cancelSessionHandler: null!,
deleteSessionHandler: null!,
listSessionsHandler: null!,
exportCalendarHandler: null!,
initiateRescheduleHandler: null!,
rescheduleTimeInputHandler: null!,
rescheduleVoteHandler: null!,
wizard: wizard,
drafts: drafts,
bot: bot,
configuration: Substitute.For<IConfiguration>(),
logger: NullLogger<UpdateRouter>.Instance);
return (sut, drafts, messenger);
}
}
@@ -0,0 +1,46 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using SharedDraft = GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the cleanup background service: each tick should call the draft
/// repository to delete expired drafts and must not propagate repository
/// failures (a transient DB blip should not bring the worker down).
/// </summary>
public sealed class WizardDraftCleanupServiceTests
{
[Fact]
public async Task RunOnceAsync_DeletesExpiredDrafts()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>()).Returns(7);
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task RunOnceAsync_OnRepositoryError_DoesNotThrow()
{
var drafts = Substitute.For<SharedDraft.IWizardDraftRepository>();
drafts.DeleteExpiredAsync(Arg.Any<CancellationToken>())
.ThrowsAsync(new InvalidOperationException("boom"));
var sut = new WizardDraftCleanupService(drafts, NullLogger<WizardDraftCleanupService>.Instance);
// Should swallow the exception — cleanup is best-effort.
await sut.RunOnceAsync(CancellationToken.None);
await drafts.Received(1).DeleteExpiredAsync(Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,75 @@
using Npgsql;
using Testcontainers.PostgreSql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
[CollectionDefinition(Name)]
public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardDraftRepositoryFixture>
{
public const string Name = "Wizard draft repository PostgreSQL";
}
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
{
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
public Task InitializeAsync()
{
return container.StartAsync().WaitAsync(ContainerTimeout);
}
public Task DisposeAsync()
{
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
}
public async Task<string> CreateSchemaDatabaseAsync()
{
var databaseName = $"wizard_drafts_{Guid.NewGuid():N}";
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
{
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
{
Database = databaseName,
Timeout = 10,
CommandTimeout = 10
}.ConnectionString;
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync().WaitAsync(ContainerTimeout);
await using var createSchema = new NpgsqlCommand(
"""
CREATE TABLE wizard_drafts (
id UUID PRIMARY KEY,
chat_id BIGINT NOT NULL,
message_thread_id INT,
owner_telegram_id BIGINT NOT NULL,
step TEXT NOT NULL,
payload JSONB NOT NULL,
draft_message_id BIGINT,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_wizard_drafts_owner
ON wizard_drafts(chat_id, message_thread_id, owner_telegram_id);
CREATE INDEX idx_wizard_drafts_expires
ON wizard_drafts(expires_at);
""",
connection);
await createSchema.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
}
return connectionString;
}
}
@@ -0,0 +1,88 @@
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Npgsql;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
[Collection(WizardDraftRepositoryCollection.Name)]
public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixture)
{
[Fact]
public async Task UpsertAsync_InsertThenUpdate_PreservesSingleRow()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
Assert.NotNull(loaded);
Assert.Equal("Title", loaded!.Step);
}
[Fact]
public async Task GetActiveAsync_ExpiredDraft_ReturnsNull()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, draft.OwnerTelegramId, CancellationToken.None);
Assert.Null(loaded);
}
[Fact]
public async Task GetActiveAsync_DifferentOwner_ReturnsNull()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.ChatId, draft.MessageThreadId, ownerTelegramId: draft.OwnerTelegramId + 1, CancellationToken.None);
Assert.Null(loaded);
}
[Fact]
public async Task DeleteExpiredAsync_DeletesOnlyExpired()
{
var connectionString = await fixture.CreateSchemaDatabaseAsync();
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None);
var deleted = await sut.DeleteExpiredAsync(CancellationToken.None);
Assert.Equal(1, deleted);
var loadedFresh = await sut.GetActiveAsync(fresh.ChatId, fresh.MessageThreadId, fresh.OwnerTelegramId, CancellationToken.None);
Assert.NotNull(loadedFresh);
}
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
MessageThreadId = null,
OwnerTelegramId = 100,
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -0,0 +1,260 @@
using System;
using System.Linq;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Verifies the shape of each step's rendered keyboard: which buttons are
/// present, where the Back/Cancel affordances sit, and that the title text
/// is non-empty. Tests use substring matching so they survive label tweaks
/// (e.g. emoji prefixes, suffix additions like "· 4 ч").
/// </summary>
public sealed class WizardStepRenderTests
{
[Fact]
public void TypeStep_HasBothChoicesAndCancel_ButNoBack()
{
var (text, kb) = Render(WizardStepNames.Type);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Одну игру", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пул игр", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
Assert.DoesNotContain(labels, l => l.Contains("Назад", StringComparison.Ordinal));
}
[Fact]
public void TitleStep_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.Title);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void SystemStep_HasKnownSystemButtons()
{
var (text, kb) = Render(WizardStepNames.System);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Fate", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Пропустить", StringComparison.Ordinal));
}
[Fact]
public void DurationStep_HasPresetButtons()
{
var (text, kb) = Render(WizardStepNames.Duration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains("3 часа", labels);
Assert.Contains("4 часа", labels);
Assert.Contains("5 часов", labels);
Assert.Contains("6 часов", labels);
}
[Fact]
public void CapacityStep_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.Capacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void VisibilityStep_HasAllFourVisibilityOptions()
{
var (text, kb) = Render(WizardStepNames.Visibility);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Публичная в общем showcase", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Публичная в витрине клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только для членов клуба", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Выбрать клуб", StringComparison.Ordinal));
}
[Fact]
public void PickClub_WithoutClubs_RendersEmptyHint()
{
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
clubs: null);
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("нет клубов", text, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void PickClub_WithOneClub_RendersClubButton()
{
var clubId = Guid.NewGuid();
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PickClub),
new WizardPayload(),
new[] { new WizardClubOption(clubId, "Awesome Club") });
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("Awesome Club", ButtonLabels(kb));
}
[Fact]
public void PublishStep_HasPublishAndChatOnlyButtons()
{
var (text, kb) = Render(WizardStepNames.Publish);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Опубликовать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Только в чате", StringComparison.Ordinal));
}
[Fact]
public void ConfirmStep_HasCreateAndCancel()
{
var (text, kb) = Render(WizardStepNames.Confirm, new WizardPayload
{
Type = WizardCreationType.Single,
Title = "My Game",
});
Assert.False(string.IsNullOrWhiteSpace(text));
Assert.Contains("My Game", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSystemDuration_HasPresetsAndCustom()
{
var (text, kb) = Render(WizardStepNames.PoolSystemDuration);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("D&D 5e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Pathfinder 2e", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Call of Cthulhu", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("GURPS", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Другое", StringComparison.Ordinal));
}
[Fact]
public void PoolAddSlots_HasAddAndDone_AndShowsCurrentCount()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "My Pool",
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4 },
new WizardSlotInput { MaxPlayers = 5 },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolAddSlots),
payload);
Assert.Contains("My Pool", text);
Assert.Contains("2", text);
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Добавить слот", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Готово", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotDateTime_HasBackAndCancel_ButNoChoiceButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotDateTime);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void PoolSlotCapacity_HasWaitlistButtons()
{
var (text, kb) = Render(WizardStepNames.PoolSlotCapacity);
Assert.False(string.IsNullOrWhiteSpace(text));
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
}
[Fact]
public void PoolConfirm_HasCreatePoolAndCancel()
{
var payload = new WizardPayload
{
Type = WizardCreationType.Pool,
Title = "Pool",
System = "Dnd5e",
DurationMinutes = 240,
Pool = new WizardPoolInput
{
Slots =
{
new WizardSlotInput { MaxPlayers = 4, Waitlist = true, ScheduledAt = DateTimeOffset.UtcNow.AddDays(7) },
new WizardSlotInput { MaxPlayers = 5, Waitlist = false, ScheduledAt = DateTimeOffset.UtcNow.AddDays(14) },
},
},
};
var (text, kb) = WizardStep.Render(
NewDraft(WizardStepNames.PoolConfirm),
payload);
Assert.Contains("Pool", text);
Assert.Contains("2", text); // slot count
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Создать пул", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
}
[Fact]
public void Render_UnknownStep_Throws()
{
var draft = new WizardDraft { Step = "Bogus" };
Assert.Throws<InvalidOperationException>(() => WizardStep.Render(draft, new WizardPayload()));
}
private static (string text, InlineKeyboardMarkup kb) Render(string step, WizardPayload? payload = null)
=> WizardStep.Render(NewDraft(step), payload ?? new WizardPayload());
private static WizardDraft NewDraft(string step) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
Step = step,
PayloadJson = "{}",
};
private static string[] ButtonLabels(InlineKeyboardMarkup kb) =>
kb.InlineKeyboard
.SelectMany(row => row.Select(b => b.Text))
.ToArray();
}
@@ -0,0 +1,184 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using GmRelay.Bot.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Microsoft.Extensions.Logging.Abstractions;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
using WizardBot = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.GameCreationWizard;
using WizardMessenger = GmRelay.Bot.Features.Sessions.CreateSession.Wizard.ITelegramWizardMessenger;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Hand-rolled test doubles and helpers for wizard unit tests. The project
/// convention is to use fakes (not a mocking framework) so the suite stays
/// AOT-friendly and the production code doesn't grow virtual members just
/// for tests.
/// </summary>
internal static class WizardTestFakes
{
public static WizardBot BuildWizard(out FakeWizardDraftRepository drafts, out FakeWizardMessenger messenger)
{
drafts = new FakeWizardDraftRepository();
messenger = new FakeWizardMessenger();
return new WizardBot(drafts, messenger, NullLogger<WizardBot>.Instance);
}
public static WizardDraft NewDraft(string step, WizardPayload? payload = null, long ownerId = 100) => new()
{
Id = Guid.NewGuid(),
ChatId = 42,
MessageThreadId = null,
OwnerTelegramId = ownerId,
Step = step,
DraftMessageId = 7,
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
};
public static Update CallbackUpdate(string data, long ownerId = 100) => new()
{
CallbackQuery = new CallbackQuery
{
Id = "cb-1",
Data = data,
From = new User { Id = ownerId, FirstName = "GM" },
Message = new Message
{
Chat = new Chat { Id = 42 },
},
},
};
public static Update TextUpdate(string text, long ownerId = 100) => new()
{
Message = new Message
{
Text = text,
Chat = new Chat { Id = 42 },
From = new User { Id = ownerId, FirstName = "GM" },
},
};
}
/// <summary>
/// Records every call the wizard makes against the draft repository. Backed by
/// an in-memory dictionary so tests can pre-seed an "active" draft for the
/// wizard to mutate.
/// </summary>
internal sealed class FakeWizardDraftRepository : IWizardDraftRepository
{
private readonly Dictionary<Guid, WizardDraft> store = new();
public List<Guid> DeletedIds { get; } = new();
public List<WizardDraft> Upserts { get; } = new();
public int ExpiredDeleted { get; set; }
public void Seed(WizardDraft draft) => store[draft.Id] = draft;
public Task<WizardDraft?> GetActiveAsync(long chatId, int? messageThreadId, long ownerTelegramId, CancellationToken ct)
{
foreach (var d in store.Values)
{
if (d.ChatId == chatId &&
d.MessageThreadId == messageThreadId &&
d.OwnerTelegramId == ownerTelegramId &&
d.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<WizardDraft?>(d);
}
}
return Task.FromResult<WizardDraft?>(null);
}
public Task UpsertAsync(WizardDraft draft, CancellationToken ct)
{
// Clone so tests can compare state without aliasing.
Upserts.Add(new WizardDraft
{
Id = draft.Id,
ChatId = draft.ChatId,
MessageThreadId = draft.MessageThreadId,
OwnerTelegramId = draft.OwnerTelegramId,
Step = draft.Step,
PayloadJson = draft.PayloadJson,
DraftMessageId = draft.DraftMessageId,
CreatedAt = draft.CreatedAt,
UpdatedAt = draft.UpdatedAt,
ExpiresAt = draft.ExpiresAt,
});
store[draft.Id] = draft;
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct)
{
DeletedIds.Add(id);
store.Remove(id);
return Task.CompletedTask;
}
public Task<int> DeleteExpiredAsync(CancellationToken ct)
{
var count = ExpiredDeleted;
ExpiredDeleted = 0;
return Task.FromResult(count);
}
}
/// <summary>
/// Records every call the wizard makes against the messenger. Default return
/// values (empty clubs, message-id 1) match what the wizard expects to see
/// in steady state.
/// </summary>
internal sealed class FakeWizardMessenger : ITelegramWizardMessenger
{
public List<(long ChatId, int? ThreadId, long MsgId, string Text)> Edits { get; } = new();
public List<string> AnsweredCallbacks { get; } = new();
public List<(long ChatId, int? ThreadId, string Text)> Sends { get; } = new();
public IReadOnlyList<WizardClubOption> Clubs { get; set; } = Array.Empty<WizardClubOption>();
public Task<long> EditMessageTextAsync(
long chatId,
int? messageThreadId,
long messageId,
string text,
InlineKeyboardMarkup keyboard,
CancellationToken ct)
{
Edits.Add((chatId, messageThreadId, messageId, text));
return Task.FromResult(messageId);
}
public Task<long> SendGroupMessageAsync(
long chatId,
int? messageThreadId,
string text,
InlineKeyboardMarkup keyboard,
CancellationToken ct)
{
Sends.Add((chatId, messageThreadId, text));
return Task.FromResult(99L);
}
public Task AnswerCallbackAsync(string callbackId, string? text, CancellationToken ct)
{
AnsweredCallbacks.Add(callbackId);
return Task.CompletedTask;
}
public Task<IReadOnlyList<WizardClubOption>> GetGmClubsAsync(long ownerTelegramId, CancellationToken ct)
=> Task.FromResult(Clubs);
}
@@ -15,6 +15,7 @@
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
@@ -8,12 +8,18 @@ public sealed class TelegramTopicIntegrationSmokeTests
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V015__add_topic_ownership.sql");
var createHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs");
var deleteHandler = await ReadRepositoryFileAsync("src/GmRelay.Bot/Features/Sessions/ListSessions/DeleteSessionHandler.cs");
var topicRouting = await ReadRepositoryFileAsync("src/GmRelay.Bot/Infrastructure/Telegram/TelegramTopicRouting.cs");
Assert.Contains("topic_created_by_bot", migration, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", createHandler, StringComparison.Ordinal);
Assert.Contains("message.MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("topicCreatedByBot", createHandler, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", createHandler, StringComparison.Ordinal);
// The wizard-driven CreateSessionHandler threads the existing forum topic
// (if any) into the draft; the shared creation command inherits it. Topic
// auto-creation and rights handling live in TelegramTopicRouting.
Assert.Contains("MessageThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ExternalThreadId", createHandler, StringComparison.Ordinal);
Assert.Contains("ResolveNewScheduleDestination", topicRouting, StringComparison.Ordinal);
Assert.Contains("MissingForumTopicRightsMessage", topicRouting, StringComparison.Ordinal);
Assert.Contains("TopicCreatedByBot", deleteHandler, StringComparison.Ordinal);
Assert.Contains("ShouldDeleteForumTopic", deleteHandler, StringComparison.Ordinal);
Assert.Contains("RemainingInTopic", deleteHandler, StringComparison.Ordinal);
@@ -0,0 +1,62 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class AuthorizedMembershipServiceTests
{
[Fact]
public async Task AuthorizedMembershipService_ShouldRequireAuthenticationForApply()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("ApplyForCurrentUserAsync", service, StringComparison.Ordinal);
Assert.Contains("User is not authenticated", service, StringComparison.Ordinal);
Assert.Contains("GetPlayerIdByPlatformIdentityAsync", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldValidateMessageLength()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("1000", service, StringComparison.Ordinal);
Assert.Contains("н", service, StringComparison.Ordinal); // Russian message length
}
[Fact]
public async Task AuthorizedMembershipService_ShouldRestrictGmActionsToGroupManagers()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("ResolveMembershipContextForGmAsync", service, StringComparison.Ordinal);
Assert.Contains("GetGroupIdForMembershipAsync", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldExposePendingApplications()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("GetPendingApplicationsAsync", service, StringComparison.Ordinal);
Assert.Contains("GetPendingApplicationsCountForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("GetMineAsync", service, StringComparison.Ordinal);
Assert.Contains("LeaveClubForCurrentUserAsync", service, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -794,10 +794,21 @@ public sealed class AuthorizedPortfolioServiceTests
public Task<WebGameGroup?> GetGroupAsync(Guid groupId) => throw new NotImplementedException();
public Task<WebPublicGroupSettings?> GetPublicGroupSettingsAsync(Guid groupId) => throw new NotImplementedException();
public Task UpdatePublicGroupSettingsAsync(Guid groupId, string? publicSlug, bool publicScheduleEnabled) => throw new NotImplementedException();
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic) => throw new NotImplementedException();
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic) => throw new NotImplementedException();
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) => throw new NotImplementedException();
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) => throw new NotImplementedException();
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode) => throw new NotImplementedException();
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) => throw new NotImplementedException();
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) => throw new NotImplementedException();
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) => throw new NotImplementedException();
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) => throw new NotImplementedException();
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) => throw new NotImplementedException();
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) => throw new NotImplementedException();
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) => throw new NotImplementedException();
public Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId) => throw new NotImplementedException();
public Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId) => throw new NotImplementedException();
@@ -824,7 +835,7 @@ public sealed class AuthorizedPortfolioServiceTests
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) => throw new NotImplementedException();
public Task<MasterProfileSettings?> GetMasterProfileSettingsAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task UpdateMasterProfileSettingsAsync(string platform, string externalUserId, string? publicSlug, bool isPublic, string displayName, string? bio) => throw new NotImplementedException();
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug) => throw new NotImplementedException();
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) => throw new NotImplementedException();
public Task<List<LinkedIdentity>> GetLinkedIdentitiesAsync(string platform, string externalUserId) => throw new NotImplementedException();
public Task LinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId, string? currentName) => throw new NotImplementedException();
public Task UnlinkIdentityAsync(string currentPlatform, string currentExternalUserId, string targetPlatform, string targetExternalUserId) => throw new NotImplementedException();
@@ -839,9 +839,11 @@ public sealed class AuthorizedSessionServiceTests
public Guid? LastPublicSessionId { get; private set; }
public Guid? LastPublicSessionGroupId { get; private set; }
public bool? LastSessionPublicValue { get; private set; }
public PublicationMode? LastSessionPublicationMode { get; private set; }
public Guid? LastPublicBatchId { get; private set; }
public Guid? LastPublicBatchGroupId { get; private set; }
public bool? LastBatchPublicValue { get; private set; }
public PublicationMode? LastBatchPublicationMode { get; private set; }
public bool RemovePlayerCalled { get; private set; }
public Guid? LastRemovedPlayerSessionId { get; private set; }
public Guid? LastRemovedPlayerGroupId { get; private set; }
@@ -888,45 +890,81 @@ public sealed class AuthorizedSessionServiceTests
return Task.CompletedTask;
}
public Task SetSessionPublicAsync(Guid sessionId, Guid groupId, bool isPublic)
public Task SetSessionPublicationModeAsync(Guid sessionId, Guid groupId, PublicationMode mode)
{
SetSessionPublicCalled = true;
LastPublicSessionId = sessionId;
LastPublicSessionGroupId = groupId;
LastSessionPublicValue = isPublic;
LastSessionPublicationMode = mode;
if (sessionsById.TryGetValue(sessionId, out var session))
{
sessionsById[sessionId] = session with { IsPublic = isPublic };
sessionsById[sessionId] = session with { PublicationMode = mode.ToDatabaseValue() };
}
return Task.CompletedTask;
}
public Task SetBatchPublicAsync(Guid batchId, Guid groupId, bool isPublic)
public Task SetBatchPublicationModeAsync(Guid batchId, Guid groupId, PublicationMode mode)
{
SetBatchPublicCalled = true;
LastPublicBatchId = batchId;
LastPublicBatchGroupId = groupId;
LastBatchPublicValue = isPublic;
LastBatchPublicationMode = mode;
foreach (var session in sessionsById.Values.Where(session => session.BatchId == batchId && session.GroupId == groupId).ToList())
{
sessionsById[session.Id] = session with { IsPublic = isPublic };
sessionsById[session.Id] = session with { PublicationMode = mode.ToDatabaseValue() };
}
return Task.CompletedTask;
}
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug) =>
public Task<WebPublicClub?> GetPublicClubBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicClub?>(null);
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId) =>
public Task<WebPublicSession?> GetPublicSessionAsync(Guid sessionId, Guid? viewerPlayerId = null) =>
Task.FromResult<WebPublicSession?>(null);
public Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsManager(groupId, telegramId));
public Task<bool> IsActiveClubMemberAsync(Guid groupId, Guid playerId) =>
Task.FromResult(false);
public Task<Guid?> GetPlayerIdByPlatformIdentityAsync(string platform, string externalUserId) =>
Task.FromResult<Guid?>(null);
public Task<IReadOnlyList<WebClubShowcaseSession>> GetClubShowcaseSessionsAsync(Guid groupId, Guid? viewerPlayerId, int page, int pageSize) =>
Task.FromResult<IReadOnlyList<WebClubShowcaseSession>>([]);
public Task<int> GetPendingApplicationsCountAsync(Guid groupId) =>
Task.FromResult(0);
public Task<List<WebPendingApplication>> GetPendingApplicationsAsync(Guid groupId) =>
Task.FromResult(new List<WebPendingApplication>());
public Task<List<WebMembership>> GetMembershipsForPlayerAsync(Guid playerId) =>
Task.FromResult(new List<WebMembership>());
public Task<Guid> ApplyForMembershipAsync(Guid groupId, Guid playerId, string? message) =>
throw new NotImplementedException();
public Task ApproveMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
throw new NotImplementedException();
public Task RejectMembershipAsync(Guid membershipId, Guid approverPlayerId) =>
throw new NotImplementedException();
public Task LeaveClubMembershipAsync(Guid membershipId, Guid playerId) =>
throw new NotImplementedException();
public Task<Guid?> GetGroupIdForMembershipAsync(Guid membershipId) =>
Task.FromResult<Guid?>(null);
public Task<PublicMasterProfile?> GetPublicMasterProfileBySlugAsync(string slug, Guid? viewerPlayerId = null) =>
Task.FromResult<PublicMasterProfile?>(null);
public Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId) =>
Task.FromResult(IsOwner(groupId, telegramId));
@@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("v3.6.0", navMenu, StringComparison.Ordinal);
Assert.Contains("v3.7.1", navMenu, StringComparison.Ordinal);
}
[Fact]
@@ -0,0 +1,94 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class ClubMembershipsTests
{
[Fact]
public async Task SessionStore_ShouldExposeMembershipMethods()
{
var sessionStore = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/ISessionStore.cs");
Assert.Contains("ApplyForMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("ApproveMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("RejectMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("LeaveClubMembershipAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPendingApplicationsAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetMembershipsForPlayerAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("IsActiveClubMemberAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetGroupIdForMembershipAsync", sessionStore, StringComparison.Ordinal);
}
[Fact]
public async Task SessionService_ShouldFilterPublicSessionsWithMemberAwareClause()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/SessionService.cs");
// Member-aware: ClubOnly only visible to Active members
Assert.Contains("publication_mode = 'ClubOnly'", service, StringComparison.Ordinal);
Assert.Contains("club_memberships", service, StringComparison.Ordinal);
Assert.Contains("cm.status = 'Active'", service, StringComparison.Ordinal);
}
[Fact]
public async Task AuthorizedMembershipService_ShouldValidateCallerForGmActions()
{
var service = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedMembershipService.cs");
Assert.Contains("IsGroupManagerAsync", service, StringComparison.Ordinal);
Assert.Contains("ApproveForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("RejectForCurrentGmAsync", service, StringComparison.Ordinal);
Assert.Contains("SessionAccessDeniedException", service, StringComparison.Ordinal);
}
[Fact]
public async Task MyClubMembershipsPage_ShouldRenderLeaveAndCancelButtons()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/MyClubMemberships.razor");
Assert.Contains("@page \"/profile/memberships\"", page, StringComparison.Ordinal);
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
Assert.Contains("Покинуть клуб", page, StringComparison.Ordinal);
Assert.Contains("Отозвать заявку", page, StringComparison.Ordinal);
Assert.Contains("Active", page, StringComparison.Ordinal);
Assert.Contains("Pending", page, StringComparison.Ordinal);
}
[Fact]
public async Task ClubApplicationsPage_ShouldRenderApproveAndReject()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/ClubApplications.razor");
Assert.Contains("/applications", page, StringComparison.Ordinal);
Assert.Contains("[Authorize]", page, StringComparison.Ordinal);
Assert.Contains("Одобрить", page, StringComparison.Ordinal);
Assert.Contains("Отклонить", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldExposeApplicationCtaAndMembersOnlyBlock()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("viewerPlayerId", page, StringComparison.Ordinal);
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -0,0 +1,104 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class ClubShowcaseSourceTests
{
[Fact]
public async Task PublicClubPage_ShouldRenderMembersOnlyBlock()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("Игры для участников клуба", page, StringComparison.Ordinal);
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
Assert.Contains("members-only-section", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldRenderApplyAndLoginCtas()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
Assert.Contains("Подать заявку", page, StringComparison.Ordinal);
Assert.Contains("Войти как участник", page, StringComparison.Ordinal);
Assert.Contains("applicationMessage", page, StringComparison.Ordinal);
Assert.Contains("ApplyForCurrentUserAsync", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicClubPage_ShouldHideMembersOnlyBlockForAnonymous()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/PublicClub.razor");
// Anonymous users must not see the members-only block content
Assert.Contains("viewerIsActiveMember", page, StringComparison.Ordinal);
// Login CTA appears when viewerPlayerId is null
Assert.Contains("viewerPlayerId is null", page, StringComparison.Ordinal);
}
[Fact]
public async Task PublicLayout_ShouldExposeClubsLink()
{
var layout = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/PublicLayout.razor");
Assert.Contains("href=\"/showcase\"", layout, StringComparison.Ordinal);
Assert.Contains("Клубы", layout, StringComparison.Ordinal);
}
[Fact]
public async Task NavMenu_ShouldExposeMyClubsLink()
{
var menu = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Layout/NavMenu.razor");
Assert.Contains("href=\"profile/memberships\"", menu, StringComparison.Ordinal);
Assert.Contains("Мои клубы", menu, StringComparison.Ordinal);
}
[Fact]
public async Task GroupDetails_ShouldExposeApplicationsLink()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
Assert.Contains("/applications", page, StringComparison.Ordinal);
Assert.Contains("Заявки участников", page, StringComparison.Ordinal);
Assert.Contains("pendingApplicationsCount", page, StringComparison.Ordinal);
}
[Fact]
public async Task GroupDetails_ShouldUsePublicationModeSelectorNotBooleanToggle()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/GroupDetails.razor");
Assert.DoesNotContain("SetSessionPublic(session.Id, !session.IsPublic)", page, StringComparison.Ordinal);
Assert.DoesNotContain("SetBatchPublic(batch, !batch.AllSessionsPublic)", page, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationMode", page, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationMode", page, StringComparison.Ordinal);
}
[Fact]
public async Task EditSession_ShouldExposePublicationModeSelector()
{
var page = await ReadRepositoryFileAsync("src/GmRelay.Web/Components/Pages/EditSession.razor");
Assert.Contains("PublicationMode", page, StringComparison.Ordinal);
Assert.Contains("Режим публикации", page, StringComparison.Ordinal);
Assert.Contains("Catalog", page, StringComparison.Ordinal);
Assert.Contains("ClubOnly", page, StringComparison.Ordinal);
Assert.Contains("Both", page, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
@@ -14,6 +14,20 @@ public sealed class PublicClubPagesTests
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldAddClubMembershipsAndPublicationMode()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
Assert.Contains("status", migration, StringComparison.Ordinal);
Assert.Contains("role", migration, StringComparison.Ordinal);
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
Assert.Contains("publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
}
[Fact]
public async Task PublicPages_ShouldExposeReadOnlyRoutesWithoutPrivateSessionData()
{
@@ -40,10 +54,10 @@ public sealed class PublicClubPagesTests
Assert.Contains("GetPublicClubBySlugAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("GetPublicSessionAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationModeAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationModeAsync", sessionStore, StringComparison.Ordinal);
Assert.Contains("g.public_schedule_enabled = true", service, StringComparison.Ordinal);
Assert.Contains("s.is_public = true", service, StringComparison.Ordinal);
Assert.Contains("s.publication_mode IN ('Catalog', 'Both')", service, StringComparison.Ordinal);
Assert.Contains("s.status <> @Cancelled", service, StringComparison.Ordinal);
Assert.DoesNotContain("p.display_name AS DisplayName,\r\n p.external_username", PublicQuerySection(service), StringComparison.Ordinal);
}
@@ -55,8 +69,8 @@ public sealed class PublicClubPagesTests
var authorizedService = await ReadRepositoryFileAsync("src/GmRelay.Web/Services/AuthorizedSessionService.cs");
Assert.Contains("UpdatePublicGroupSettingsForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetSessionPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("SetBatchPublicationModeForCurrentUserAsync", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("PublicSessionUrl", groupDetailsPage, StringComparison.Ordinal);
Assert.Contains("NormalizePublicSlug", authorizedService, StringComparison.Ordinal);
Assert.Contains("IsGroupManagerAsync", authorizedService, StringComparison.Ordinal);
@@ -0,0 +1,79 @@
namespace GmRelay.Bot.Tests.Web;
public sealed class PublicationModeTests
{
[Fact]
public void PublicationMode_ShouldHaveFourValues()
{
var values = Enum.GetValues<GmRelay.Shared.Domain.PublicationMode>();
Assert.Equal(4, values.Length);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.None, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Catalog, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.ClubOnly, values);
Assert.Contains(GmRelay.Shared.Domain.PublicationMode.Both, values);
}
[Fact]
public async Task MigrationV030_ShouldAddClubMembershipsTable()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("CREATE TABLE club_memberships", migration, StringComparison.Ordinal);
Assert.Contains("status", migration, StringComparison.Ordinal);
Assert.Contains("role", migration, StringComparison.Ordinal);
Assert.Contains("Pending", migration, StringComparison.Ordinal);
Assert.Contains("Active", migration, StringComparison.Ordinal);
Assert.Contains("Rejected", migration, StringComparison.Ordinal);
Assert.Contains("Left", migration, StringComparison.Ordinal);
Assert.Contains("ux_club_memberships_one_active", migration, StringComparison.Ordinal);
Assert.Contains("Member", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldReplaceIsPublicWithPublicationModeEnum()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("ADD COLUMN publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("ck_sessions_publication_mode", migration, StringComparison.Ordinal);
Assert.Contains("'None', 'Catalog', 'ClubOnly', 'Both'", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE sessions SET publication_mode = 'Both' WHERE is_public = true", migration, StringComparison.Ordinal);
Assert.Contains("UPDATE sessions SET publication_mode = 'None' WHERE is_public = false", migration, StringComparison.Ordinal);
Assert.Contains("DROP COLUMN is_public", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldRecreatePartialIndexUsingPublicationMode()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("ix_sessions_public_schedule", migration, StringComparison.Ordinal);
Assert.Contains("publication_mode IN ('Catalog', 'Both')", migration, StringComparison.Ordinal);
}
[Fact]
public async Task MigrationV030_ShouldAddPortfolioPublicationModeColumn()
{
var migration = await ReadRepositoryFileAsync("src/GmRelay.Bot/Migrations/V030__add_club_memberships_and_publication_mode.sql");
Assert.Contains("portfolio_games", migration, StringComparison.Ordinal);
Assert.Contains("ix_portfolio_games_showcase", migration, StringComparison.Ordinal);
}
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(directory.FullName, relativePath);
if (File.Exists(candidate))
{
return await File.ReadAllTextAsync(candidate);
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
}
+20 -6
View File
@@ -28,6 +28,15 @@
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"NSubstitute": {
"type": "Direct",
"requested": "[5.3.0, )",
"resolved": "5.3.0",
"contentHash": "lJ47Cps5Qzr86N99lcwd+OUvQma7+fBgr8+Mn+aOC0WrlqMNkdivaYD9IvnZ5Mqo6Ky3LS7ZI+tUq1/s9ERd0Q==",
"dependencies": {
"Castle.Core": "5.1.1"
}
},
"SecurityCodeScan.VS2019": {
"type": "Direct",
"requested": "[5.6.7, )",
@@ -84,6 +93,11 @@
"resolved": "2.6.2",
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
},
"Castle.Core": {
"type": "Transitive",
"resolved": "5.1.1",
"contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g=="
},
"Dapper": {
"type": "Transitive",
"resolved": "2.1.72",
@@ -487,8 +501,8 @@
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.5.1, )",
"GmRelay.Shared": "[3.5.1, )",
"GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.7.1, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.5.3, )",
"dbup-postgresql": "[7.0.1, )"
@@ -500,8 +514,8 @@
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"Dapper.AOT": "[1.0.48, )",
"GmRelay.ServiceDefaults": "[3.5.1, )",
"GmRelay.Shared": "[3.5.1, )",
"GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.7.1, )",
"NetCord.Hosting": "[1.0.0-alpha.489, )",
"NetCord.Hosting.Services": "[1.0.0-alpha.489, )",
"NetCord.Services": "[1.0.0-alpha.489, )",
@@ -532,8 +546,8 @@
"dependencies": {
"Aspire.Npgsql": "[13.2.2, )",
"Dapper": "[2.1.72, )",
"GmRelay.ServiceDefaults": "[3.5.1, )",
"GmRelay.Shared": "[3.5.1, )",
"GmRelay.ServiceDefaults": "[3.7.1, )",
"GmRelay.Shared": "[3.7.1, )",
"Npgsql": "[10.0.2, )",
"Telegram.Bot": "[22.9.6.1, )"
}