Add a PostgreSQL integration regression test for new-platform-group session creation. The production failure was a missing Platform parameter in the group_managers insert, leaving @Platform in SQL and causing PostgreSQL 42883. Bump version to 3.9.8.
Fix two wizard FSM bugs reported after v3.9.4:
1. Capacity waitlist buttons could still advance the draft without a
numeric MaxPlayers value. The final submit validation then rejected
the draft with 'Не заполнены поля: лимит мест'. Now waitlist:on/off
stay on Capacity until MaxPlayers is set; users must either enter a
numeric limit or explicitly choose '♾ Без лимита'.
2. PickClub computed NextAfterVisibility before SetClubId, so the first
club click left the wizard on PickClub and the second click advanced.
Now ClubId is saved first and NextAfterVisibility is evaluated after
that mutation, so a valid club click advances on the first try.
TDD:
- WaitlistChoiceWithoutCapacity_StaysOnCapacityStep covers waitlist:on/off.
- PickClub_ValidGuid_AdvancesToPublishOnFirstClick covers the single-click club path.
- Stale Capacity waitlist callback test updated to the safer no-advance contract.
Closes#127
In the session creation wizard (Telegram + Discord), the Capacity step
only exposed waitlist on/off buttons. The 'no waitlist' button silently
advanced to the next step without setting MaxPlayers, so users who tried
to create a session with no player cap were blocked with
'Не заполнены поля: лимит мест'.
The DB contract and CreateSessionCommand already supported null
MaxPlayers (int?, ck_sessions_max_players check in V006), and the web
form already exposes 'Без лимита' as an empty InputNumber — only the
wizard flow was broken.
Changes:
- Add '♾ Без лимита' choice button to Capacity in shared
WizardStepViewBuilder.BuildCapacity (Telegram) and to
RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord).
- Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that
sets MaxPlayers to null and advances to Visibility.
- Change GameCreationWizard.SetMaxPlayers signature from int to int? so
the 'no limit' branch compiles.
- Change CreateSessionCommand builder in both Telegram and Discord
submitters to take int? maxPlayers and drop the '?? 0' that would
have turned null into 0 (violating the DB CHECK and the 'no limit'
contract).
- In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist
вкл/выкл' when MaxPlayers is null (the previous code silently
omitted the line).
- Expose BuildCommand as internal in both submitters and add
InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly
for unit-test access.
Tests (9 new):
- WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the
'Без лимита' button is present.
- GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… —
asserts the choice advances to Visibility and leaves MaxPlayers null
in the JSON draft.
- GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep —
new Theory row for Capacity/no_limit.
- CreateSessionHandlerBuildCommandTests (new) — null/value propagation
through the Telegram submitter's BuildCommand.
- DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is
rendered for both Capacity and PoolSlotCapacity, with the expected
custom-id shape.
- DiscordWizardSubmitterBuildCommandTests (new) — null/value
propagation through the Discord submitter's BuildCommand.
Closes#123
Production regression in 3.9.0: Telegram bot silently dropped every update.
WizardDraftRepository.GetActiveAsync was called on every Telegram update
(via UpdateRouter -> TryGetWizardContext) and threw
System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48
only generates interceptors for the (sql, object?) extension overloads and
NOT for the (CommandDefinition) overload. The runtime then fell back to
Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
fails on AOT. TelegramBotService swallowed the exception, so /newsession
appeared to start but no button press reached the wizard and no session was
created.
Two related changes in WizardDraft:
1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params,
cancellationToken: ct)' to the direct 'connection.Query*(sql, params)'
overload, matching the working pattern in JoinSessionHandler. Dapper.AOT
now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> +
QuerySingleOrDefaultAsync37<WizardDraft> for all four methods.
2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead
of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and
does not perform DateTime -> DateTimeOffset conversion; the previous type
raised InvalidCastException on the very first wizard_drafts query.
All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests
in WizardDraftRepositoryAotShapeTests). dotnet format clean.
Bumps: 3.9.0 -> 3.9.1.
Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and
DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition
and will hit the same Reflection.Emit AOT failure when the user reaches the
PickClub visibility step. Follow-up in 3.9.2.
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.
- 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.
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>
- PublicSession.razor: добавлена обработка ?register=1, AuthStateProvider,
TryGetPlatformIdentity, кнопки записи для авторизованных/неавторизованных
пользователей, отображение результата регистрации
- CreateSessionHandler: добавлен cover_image_url в INSERT SQL
- DiscordProjectStructureTests: версия 3.4.0 во всех проверках
- README.md: актуальная версия v3.4.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Добавлены миграции V024 (backfill + deprecation comments + calendar_subscriptions platform identity) и V025 (backfill proposed_by_external_user_id)
- Все Bot handlers переведены с telegram_id/chat_id на platform + external_*
- Shared handlers очищены от COALESCE fallback с telegram_* колонками
- DiscordBot очищен от COALESCE fallback
- Web SessionService и CalendarSubscriptionService переведены на external_*
- HandleRsvpHandler: убран legacy UNION с gm_telegram_id, теперь только group_managers
- RescheduleVotingFinalizer: переведен на external_username/external_user_id
- Tests: добавлены asserts для V024/V025
- Версия обновлена до 3.1.0
Bump version → 3.1.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SessionSchedulerService now backs off for 15 minutes after any
handler failure (confirmation, one-hour reminder, join link),
preventing infinite retry loops on Discord 403 Missing Access.
- Added per-session ConcurrentDictionary backoff tracking with
automatic cleanup on success.
- Enhanced DiscordPlatformMessenger logging for SendConfirmation
and SendJoinLink to aid permission diagnostics.
- Added 3 regression tests for backoff behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both Telegram and Discord deadline services were querying ALL due
proposals without filtering by source_platform. If the Telegram
service reached a Discord proposal first, it finalized the DB state
but skipped message handling. The Discord service then saw status
!= 'Voting' and never updated the Discord vote message.
Fix: GetDueProposalIdsAsync now accepts a sourcePlatform parameter
and filters at the DB level. Each service only processes its own
platform's proposals.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Move neutral join/leave handlers into GmRelay.Shared so Telegram and Discord share capacity, waitlist, duplicate-click, and schedule-update behavior.
Add Discord component routing for join_session and leave_session buttons with deferred ephemeral replies and serialized schedule message updates.
Bump version to 2.5.0 and update Discord docs.
Refs #29
- Render SessionBatchViewModel into NetCord EmbedProperties + ActionRowProperties
- One embed per session with game title, Moscow date, players, capacity, waitlist, status
- Buttons map AvailableAction to ButtonProperties with platform-neutral custom IDs
- Cancelled sessions get embed but no action row
- Full sessions trigger waitlist button label
- 7 tests covering open/full/waitlist/cancelled/reschedule states
Closes#27
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduce platform-neutral PlatformKind, PlatformUser, PlatformGroup, and IPlatformMessenger contracts in GmRelay.Shared.
Route Telegram session schedule updates, direct notifications, interaction replies, and calendar export through TelegramPlatformMessenger while preserving existing Telegram behavior.
Bump version -> 2.0.1
- Add db-backup service to compose.yaml (postgres:17-alpine + cron)
- Add pgbackups volume for backup storage
- Add scripts/restore.sh for manual restore from latest backup
- Update .env.example with BACKUP_RETENTION_DAYS and BACKUP_VOLUME_NAME
- Document backup/restore flow in README
Bump version -> 1.15.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Enable NuGet lock files so Trivy has dependency targets, fail PR checks when no lock files or language-specific files are detected, and let the installer fetch the latest Trivy release.
Route new schedules to an existing forum topic when /newsession is sent inside one, create bot-owned topics only from the forum root, and keep group notifications/dashboard updates threaded to the stored topic.
Persist topic ownership so deletion only removes empty bot-created topics, add topic routing tests and smoke coverage, and bump release metadata to 1.14.0.
- SessionBatchDto: добавлено поле JoinLink
- SessionViewItem: добавлено поле JoinLink
- SessionBatchViewBuilder: прокидывание JoinLink из DTO в ViewModel
- CreateSessionHandler, SessionService: обновлены все вызовы конструктора
- TelegramSessionBatchRenderer (Bot + Web): рендеринг ссылки в карточке
- Добавлены тесты на наличие ссылки в рендере
- Все 7 SQL-запросов, загружающих SessionBatchDto, обновлены с join_link AS JoinLink
- Бамп версии до 1.11.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- SessionBatchViewBuilder в Shared собирает нейтральную view model
- TelegramSessionBatchRenderer в Bot/Web рендерит HTML + InlineKeyboardMarkup
- DiscordSessionBatchRenderer заглушка подготовлена
- BatchMessageEditor перенесён из Shared в Bot/Web
- Удалён SessionBatchRenderer, убран Telegram.Bot из Shared.csproj
- Обновлены все вызовы (7 handler-ов + Web SessionService + smoke tests)
- Новые тесты на builder и Telegram renderer
When creating a session with an image, send it as a single SendPhoto
with the schedule text as caption (+ reply markup), instead of two
separate messages. Falls back to two messages if caption exceeds
Telegram's 1024-char limit.
Also adds BatchMessageEditor helper that transparently handles
EditMessageText vs EditMessageCaption depending on whether the batch
message is a text or photo message. Updated all handlers and web
service to use this helper.
Version bump to 1.9.7.