Adds the missing inbound handlers for the Discord wizard that the
previous commit (b81d865) left out. Three thin NetCord module classes
share one WizardInteractionDispatcher:
- DiscordWizardButtonModule
- DiscordWizardStringMenuModule
- DiscordWizardModalModule
Each registers a single [ComponentInteraction("wizard")] method that
hands the args string to the dispatcher. The dispatcher parses the
custom-id tail (btn:choice:<step>:<value>, btn:back, btn:cancel,
btn:create, btn:resume:continue, btn:resume:restart, select:<step>,
modal:<step>), looks up the active draft by (platform="Discord",
ownerId=userId), and routes through the shared
GameCreationWizard.HandleInteractionAsync. The "create" callback
delegates to DiscordWizardSubmitter.SubmitAsync (3-retry finalize).
Program.cs gets 4 new singleton registrations (the dispatcher plus
the three module classes) and 2 new AddComponentInteractions calls
(StringMenu + Modal). The existing Button registration is unchanged.
12 new source-level smoke tests in DiscordWizardInteractionModuleSourceTests
cover the file shape: 3 handler classes, 3 base classes, 3
[ComponentInteraction] registrations, all 5 callback kinds parsed,
GameCreationWizard wired in, submitter invoked on create, Program.cs
registers all 3 AddComponentInteractions and all 4 module classes,
draft lookup by GetActiveAsync("Discord", ...), modal walks
Components[0] -> TextInput -> .Value, string menu reads
SelectedValues[0].
Build green. 190/190 Discord+Wizard tests pass (2 pre-existing
skipped). dotnet format clean.
Add Discord adapter for the platform-neutral wizard moved to Shared in
the previous commit. Six new files in src/GmRelay.DiscordBot/Features/
Sessions/Wizard/:
- DiscordWizardContextStore: IWizardContextStore abstraction +
thread-safe in-memory impl keyed by draft id. Holds the (guild,
channel, message) coordinates the messenger needs to re-send the
draft after a 15-minute interaction token expires.
- DiscordWizardStep: renderer for all 15 wizard steps. Returns an
embed plus an IReadOnlyList<IMessageComponentProperties> that mixes
ActionRow buttons with StringMenu select menus. Also exposes
BuildModal for the 8 modal-collecting steps.
- DiscordWizardMessenger: IWizardMessenger impl backed by NetCord's
RestClient + NpgsqlDataSource. Edit falls back to re-send on
401/403/404. Toast replies are stashed in the existing
DiscordInteractionReplyCache.
- DiscordWizardSubmitter: 3-retry finalize loop. Builds the shared
CreateSessionCommand and calls CreateSessionHandler; on success
edits the message to "ok Created: N sessions", on failure shows
retry/cancel buttons.
- DiscordWizardCommand: /newsession-wizard slash command with an
optional mode param (single|pool). Owner/co-GM check via the
shared group_managers table.
- DiscordPermissionLookup: small helper that loads DB manager ids
for a guild.
Program.cs gets 5 new singleton registrations (IWizardDraftRepository,
IWizardContextStore, IWizardMessenger, GameCreationWizard,
DiscordWizardSubmitter). The slash command is auto-discovered by
AddApplicationCommands<SlashCommandInteraction, SlashCommandContext>()
+ AddModules(typeof(Program).Assembly).
Build green. All 85 wizard tests + 95 Discord tests pass.
dotnet format clean.
Open: DiscordWizardInteractionModule (button/modal handlers) is not
yet implemented; the bot starts and /newsession-wizard works to the
point of posting the first embed, but subsequent button clicks won't
be handled. A follow-up commit will add the component-interaction
module.
Moves the game-creation wizard state machine, view builder, and
platform-neutral contracts (callback data, step names, storage
exception, club option, step limits) from GmRelay.Bot to GmRelay.Shared.
Telegram continues to work through a new TelegramWizardMessenger
implementing IWizardMessenger and a WizardInteractionMapper that
converts Update → WizardInteraction. Wires the new platform column on
wizard_drafts (V032 migration) and switches chat/owner/thread/message
ids to TEXT so the same table can hold Discord snowflakes later.
- GameCreationWizard: now in Shared, takes IWizardMessenger +
IWizardDraftRepository, dispatches on WizardInteraction.
- New IWizardMessenger contract with Edit/Send/Answer/GetOwnerClubs
(returns string ids so Telegram longs and Discord snowflakes both
fit).
- New WizardStepViewBuilder in Shared returns
(text, IReadOnlyList<WizardAction>); TelegramWizardMessenger
renders actions into InlineKeyboardMarkup via a new Bot-side
ToInlineKeyboard helper.
- New WizardInteractionMapper in Bot (5-case test) converts Telegram
Update to WizardInteraction.
- WizardDraft gains a Platform column; ChatId/MessageThreadId/OwnerId/
DraftMessageId switched to string. V032 migrates existing rows and
rebuilds the owner lookup index on (platform, owner_id).
- All existing wizard / create-session tests updated to the new
contract (HandleInteractionAsync + WizardInteraction). Wizard
callback-data format preserved.
- dotnet build clean, dotnet format --verify-no-changes clean, all
101 wizard tests pass.
Game-creation wizard: replace /newsession text template with step-by-step
inline-keyboard flow for single games and game pools.
- Directory.Build.props: 3.7.1 -> 3.8.0
- compose.yaml: pin bot, discord-bot and web images to 3.8.0
- deploy.yml VERSION env: 3.7.1 -> 3.8.0
- NavMenu.razor: nav-version 3.7.1 -> 3.8.0
🤖 Generated with Claude Code
@
- 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.
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.
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).
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>
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>