Compare commits

...

103 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
Toutsu 21e29564f6 docs: document portfolio release and bump version to 3.6.0
PR Checks / test-and-build (pull_request) Successful in 8m32s
2026-06-02 16:07:01 +03:00
Claude 401653a4d1 feat(web): publish completed game portfolios 2026-06-02 15:41:43 +03:00
Toutsu e970e94e00 feat(web): add portfolio management UI 2026-06-02 15:21:51 +03:00
Claude 242ff99a83 feat(web): authorize portfolio management and reviews 2026-06-02 15:01:29 +03:00
Toutsu f2c9f34ab4 feat(web): add portfolio persistence 2026-06-02 14:46:57 +03:00
Toutsu e5945288ac feat(web): add local portfolio cover storage 2026-06-02 12:35:00 +03:00
Toutsu 7d1489445e feat(web): define portfolio contracts and validation 2026-06-02 12:21:55 +03:00
Toutsu 4af4e52778 docs: sync portfolio task 1 review index 2026-06-02 10:32:34 +03:00
Toutsu a20da4b1a0 fix(data): serialize portfolio mutations before rows 2026-06-02 10:32:13 +03:00
Toutsu edf40c9a09 docs: sync portfolio task 1 review index 2026-06-02 07:57:46 +03:00
Toutsu 1a8161027c fix(data): reject stale reschedule snapshots 2026-06-02 07:57:30 +03:00
Toutsu 85918c1e5d docs: sync portfolio task 1 review index 2026-06-02 07:31:54 +03:00
Toutsu ea714480d3 fix(data): serialize new-link publication races 2026-06-02 07:31:35 +03:00
Toutsu 1d62f69ff0 fix(data): lock racing portfolio publications 2026-06-02 07:10:37 +03:00
Toutsu d762ecc377 fix(data): serialize portfolio future reschedules 2026-06-01 20:58:53 +03:00
Toutsu a28b75dd5b fix(data): align portfolio mutation lock order 2026-06-01 20:23:43 +03:00
Toutsu 2b725708ef test(discord): keep Moscow time parsing fixture in future 2026-06-01 20:00:59 +03:00
Toutsu da0a306340 fix(data): enforce completed portfolio sessions 2026-06-01 15:04:20 +03:00
Toutsu f493836b77 fix(data): reject stale portfolio trigger snapshots 2026-06-01 14:39:04 +03:00
Toutsu 6e7a0cb493 fix(data): enforce portfolio validation isolation 2026-06-01 14:28:51 +03:00
Toutsu 76b3ff7ddf fix(data): serialize portfolio publication validation 2026-06-01 14:12:29 +03:00
Toutsu 536061f63c docs: sync portfolio task 1 review indexes 2026-06-01 10:04:44 +03:00
Toutsu f7a12d14d2 docs: document portfolio concurrency hardening plan 2026-06-01 09:56:33 +03:00
Toutsu 3c1a98bcc4 fix(data): harden portfolio publication concurrency 2026-06-01 09:46:18 +03:00
Toutsu d591e5ed5a fix(data): protect portfolio publication invariant 2026-06-01 09:20:27 +03:00
Toutsu 5809a470b9 test(data): scope portfolio migration assertions 2026-06-01 09:07:47 +03:00
Toutsu ed842d2195 test(data): harden portfolio migration contract 2026-05-30 23:37:40 +03:00
Toutsu a0040ec9fb test(data): tighten portfolio moderation schema assertion 2026-05-30 23:25:12 +03:00
Toutsu 67b8aafd97 feat(data): add completed game portfolio schema 2026-05-30 23:21:31 +03:00
Toutsu ac417731d6 docs: plan completed game portfolio implementation 2026-05-30 21:36:05 +03:00
Toutsu 991c7e1965 docs: specify completed game portfolio 2026-05-30 14:16:12 +03:00
Toutsu 0d9df29f58 Merge pull request #116: feat(web): redesign profile page to match design system
Deploy Telegram Bot / build-and-push (push) Successful in 6m5s
Deploy Telegram Bot / scan-images (push) Successful in 3m44s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-29 15:19:45 +03:00
Toutsu d54950698a feat(web): redesign profile page to match design system
PR Checks / test-and-build (pull_request) Successful in 12m8s
- Rewrite Profile.razor to use .page-container, .glass-card, .gm-alert,

  .btn-gm, .status-badge, .empty-state and other standard design system classes

- Replace custom unstyled markup with breadcrumb, page-header, skeleton loaders

- Add .identity-list styles to app.css for linked accounts section

- Unify visual language with Home, Templates and GroupDetails pages
2026-05-29 15:15:48 +03:00
Toutsu 394bd19b95 Merge pull request #114: fix(web): restore public game pages
Deploy Telegram Bot / build-and-push (push) Successful in 6m0s
Deploy Telegram Bot / scan-images (push) Successful in 3m30s
Deploy Telegram Bot / deploy (push) Successful in 30s
2026-05-29 09:40:20 +03:00
Toutsu b52d4000b4 fix(web): restore public game pages
PR Checks / test-and-build (pull_request) Successful in 11m56s
Use the existing group_managers.created_at column when picking owner profile links for public pages.

Bump version -> 3.5.1
2026-05-29 09:27:01 +03:00
Toutsu b32f962f11 Merge pull request #113: feat(web): add public master profiles
Deploy Telegram Bot / build-and-push (push) Successful in 6m20s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 28s
2026-05-29 00:22:43 +03:00
Toutsu 0c1d3abd7e feat(web): add public master profiles
PR Checks / test-and-build (pull_request) Successful in 12m32s
Add sanitized public GM profiles with publication controls, public /gm/{slug} pages, and links from public game surfaces.

Bump version -> 3.5.0
2026-05-29 00:08:14 +03:00
Toutsu d81564c308 Merge pull request #109: feat: добавить каталог игр и витрину ваншотов (issue #39)
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 3m21s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 17:56:53 +03:00
Toutsu accb3b2405 fix(web): правильный парсинг query string для ?register=1
PR Checks / test-and-build (pull_request) Successful in 12m50s
Заменен Navigation.Uri.Contains() на QueryHelpers.ParseQuery
для корректного определения параметра register без ложных
срабатываний на подстроки (например, register=10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:28:51 +03:00
Toutsu a63e3bef1e fix: финальные правки ревью для issue #39
PR Checks / test-and-build (pull_request) Successful in 13m16s
- 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>
2026-05-28 17:00:33 +03:00
Toutsu 9d9aca53df chore: bump version to 3.4.0 2026-05-28 16:43:11 +03:00
Toutsu 5b6971fda5 test: consolidate capacity tests, add GameSystem edge cases, remove ShowcaseQueryTests 2026-05-28 16:40:11 +03:00
Toutsu b496a401fc test: add GameSystem fuzzy matching and showcase query tests
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:33:29 +03:00
Toutsu 76c6818952 fix(shared): add missing Platform parameter in players upsert 2026-05-28 16:28:25 +03:00
Toutsu 633a020212 fix(shared): convert GameSystem to string in SQL, guard rollback after commit 2026-05-28 16:26:54 +03:00
Toutsu ab38238fe8 feat(shared): extend CreateSessionCommand with showcase metadata
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:20:44 +03:00
Toutsu 4145cacc52 fix(web): add session-description styles for public session detail 2026-05-28 16:18:58 +03:00
Toutsu 6d59737d07 feat(web): update public session detail with showcase fields
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:13:35 +03:00
Toutsu 71ffcce06b fix(web): add try/finally, concurrency guard, accessible label, registration link to showcase 2026-05-28 16:07:09 +03:00
Toutsu 72f43dbef2 feat(web): add /showcase catalog page with filters
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:00:21 +03:00
Toutsu a5f4a68c6a fix(web): add public-session guards and ON CONFLICT to RegisterFromShowcaseAsync 2026-05-28 15:40:21 +03:00
Toutsu b2497ed877 feat(web): add showcase query and registration methods 2026-05-28 15:27:18 +03:00
Toutsu 9b42ea034a fix(shared): align GameSystem enum with spec, make TryParseFuzzy nullable and display-name based 2026-05-28 15:06:39 +03:00
Toutsu f94bea3e74 feat(shared): add GameSystem enum and Showcase DTOs
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:58:49 +03:00
Toutsu cde1e4311f feat(db): V027 add showcase fields to sessions 2026-05-28 14:46:04 +03:00
Toutsu 847a40815f docs: add implementation plan for issue #39 2026-05-28 14:41:12 +03:00
Toutsu 6fd03ef836 docs: add design spec for issue #39 game catalog and showcase 2026-05-28 14:31:17 +03:00
Toutsu c2ccc35e50 Merge pull request #107: feat: add public club pages
Deploy Telegram Bot / build-and-push (push) Successful in 6m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-28 12:38:33 +03:00
Toutsu 3418d1a46c feat: add public club pages
PR Checks / test-and-build (pull_request) Successful in 12m47s
Add publication settings for clubs and sessions, read-only public club/session pages, dashboard controls, privacy-focused public queries, docs, and tests.

Bump version to 3.3.0
2026-05-28 12:23:47 +03:00
Toutsu fac5d75c7e Fix Discord co-GM management
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m39s
Deploy Telegram Bot / deploy (push) Successful in 34s
2026-05-27 16:32:47 +03:00
Toutsu 7a2965b43f fix(bot): add missing DI registrations for shared DeleteSessionHandler and ListSessionsHandler
Deploy Telegram Bot / build-and-push (push) Successful in 6m39s
Deploy Telegram Bot / scan-images (push) Successful in 3m26s
Deploy Telegram Bot / deploy (push) Successful in 29s
PR #106 extracted DeleteSessionHandler and ListSessionsHandler to GmRelay.Shared,
but forgot to register the shared implementations in Program.cs. This caused
an InvalidOperationException at startup on Native AOT builds because the Bot
wrappers could not resolve their shared dependencies.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:58:51 +03:00
Toutsu a0df94fc91 Merge branch 'main' of ssh://git.codeanddice.ru:222/Toutsu/GmRelayBot
Deploy Telegram Bot / build-and-push (push) Successful in 7m11s
Deploy Telegram Bot / scan-images (push) Successful in 3m27s
Deploy Telegram Bot / deploy (push) Failing after 1m3s
2026-05-27 15:19:32 +03:00
Toutsu 79694f7de8 Merge pull request #106: refactor: extract remaining Telegram handlers to platform-neutral contracts 2026-05-27 15:19:23 +03:00
Toutsu 542f15f2d6 refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler,
  ExportCalendarHandler, HandleRescheduleTimeInputHandler,
  HandleRescheduleVoteHandler to GmRelay.Shared
- Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync,
  SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync
- Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers
- Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler
- Update UpdateRouter with explicit type aliases for ambiguous handler names
- Add contract and source-inspection tests for extracted handlers
- Bump version 3.1.1 → 3.2.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 14:52:09 +03:00
Toutsu 64216f5a26 Merge pull request #105: fix template batch topics
Deploy Telegram Bot / build-and-push (push) Successful in 6m3s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-27 14:05:38 +03:00
Toutsu 383e2c1d8d fix: create Telegram topics for template batches
PR Checks / test-and-build (pull_request) Successful in 12m56s
Create a Telegram forum topic when Web creates a batch from a campaign template, persist thread ownership on the generated sessions, and send the batch schedule into that topic.

Bump version -> 3.1.1
2026-05-27 13:50:18 +03:00
Toutsu bfa979a224 Merge pull request #104: refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
Deploy Telegram Bot / build-and-push (push) Successful in 6m43s
Deploy Telegram Bot / scan-images (push) Successful in 3m25s
Deploy Telegram Bot / deploy (push) Successful in 30s
- Migrated all core domain SQL from telegram_* columns to platform + external_*
- Added V024/V025 migrations with backfill and deprecation comments
- Removed all COALESCE(external_*, telegram_*) fallbacks
- Replaced gm_telegram_id join with group_managers query in HandleRsvpHandler
- Updated Bot, Web, DiscordBot, and Shared handlers
- Bumped version to 3.1.0

CI run #265 passed (289 tests, 0 warnings, 0 vulnerabilities)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 17:19:47 +03:00
Toutsu c69ebf6c03 test: update DiscordProjectStructureTests version asserts to 3.1.0
PR Checks / test-and-build (pull_request) Successful in 13m24s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 16:58:18 +03:00
Toutsu 040b0a3cdb refactor: завершить platform migration и удалить deprecated telegram_* scaffolding
PR Checks / test-and-build (pull_request) Failing after 13m15s
- Добавлены миграции 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>
2026-05-26 16:41:15 +03:00
Toutsu a5aed14dd2 fix(discord): add backoff to scheduler to prevent 403 spam
Deploy Telegram Bot / build-and-push (push) Successful in 6m37s
Deploy Telegram Bot / scan-images (push) Successful in 3m45s
Deploy Telegram Bot / deploy (push) Successful in 33s
- 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>
2026-05-26 15:51:25 +03:00
Toutsu 9fc434b42b fix(discord): treat /newsession and /reschedule input as Moscow time (UTC+3)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 3m20s
Deploy Telegram Bot / deploy (push) Successful in 34s
DiscordNewSessionHandler.ParseTimeInput used DateTimeStyles.AssumeUniversal,
which interpreted user input as UTC. A user entering 15:00 got a session
scheduled at 18:00 MSK after rendering. Align with Telegram behavior by
treating input as Moscow time and converting to UTC before storage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 15:12:00 +03:00
Toutsu c2cc7fd9a8 fix(web): show discord sessions and integration labels
Deploy Telegram Bot / build-and-push (push) Successful in 5m46s
Deploy Telegram Bot / scan-images (push) Successful in 3m29s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 14:43:33 +03:00
Toutsu 3447acd8c4 fix(discord): update sessions via interactions
Deploy Telegram Bot / build-and-push (push) Successful in 6m14s
Deploy Telegram Bot / scan-images (push) Successful in 3m12s
Deploy Telegram Bot / deploy (push) Successful in 31s
2026-05-26 14:24:06 +03:00
Toutsu 56aeca5288 fix(discord): sanitize embed join links
Deploy Telegram Bot / build-and-push (push) Successful in 5m53s
Deploy Telegram Bot / scan-images (push) Successful in 3m6s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:57:11 +03:00
Toutsu 6ed0a120a0 fix(discord): avoid duplicate schedule send after new session
Deploy Telegram Bot / build-and-push (push) Successful in 6m0s
Deploy Telegram Bot / scan-images (push) Successful in 3m22s
Deploy Telegram Bot / deploy (push) Successful in 29s
2026-05-26 13:40:59 +03:00
Toutsu 682dd3fdec Merge pull request #103: fix(db): make legacy telegram_* columns nullable for Discord multi-platform
Deploy Telegram Bot / build-and-push (push) Successful in 13m30s
Deploy Telegram Bot / scan-images (push) Successful in 3m35s
Deploy Telegram Bot / deploy (push) Successful in 33s
2026-05-26 13:10:58 +03:00
Toutsu c955e1572f fix(db): make legacy telegram_* columns nullable for Discord multi-platform
PR Checks / test-and-build (pull_request) Successful in 18m38s
V023 migration drops NOT NULL constraints on:
- game_groups.telegram_chat_id
- game_groups.gm_telegram_id
- players.telegram_id

This allows Discord (and future platforms) to create players and
game_groups without legacy Telegram identifiers.

Bump version → 3.0.10

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:51:45 +03:00
Toutsu a9aa84af0f Merge pull request #102: fix(discord): add missing Dapper.AOT reference to DiscordBot project
Deploy Telegram Bot / build-and-push (push) Successful in 5m58s
Deploy Telegram Bot / scan-images (push) Successful in 3m19s
Deploy Telegram Bot / deploy (push) Successful in 35s
fix(discord): add missing Dapper.AOT reference to DiscordBot project

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:32:26 +03:00
Toutsu dcbd9bab41 fix(discord): add missing Dapper.AOT reference to DiscordBot project
PR Checks / test-and-build (pull_request) Successful in 11m4s
GmRelay.Shared references Dapper.AOT with PrivateAssets=all, which
prevents the runtime DLL from flowing to downstream projects. Telegram
bot works because it explicitly references Dapper.AOT directly, but
Discord bot did not — causing FileNotFoundException for Dapper.AOT
at runtime, breaking the scheduler and slash commands.

- Add Dapper.AOT 1.0.48 to GmRelay.DiscordBot.csproj
- Add regression test: DiscordWorkerProject_ShouldExist asserts
  Dapper.AOT is present in the DiscordBot csproj
- Bump version → 3.0.9

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:20:56 +03:00
184 changed files with 21686 additions and 1751 deletions
+3
View File
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
# Имя Docker volume для резервных копий БД
BACKUP_VOLUME_NAME=game_pgbackups
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.0.8
VERSION: 3.7.1
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.0.8</Version>
<Version>3.7.1</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+32 -1
View File
@@ -4,7 +4,7 @@
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
**Текущая версия:** `v2.8.0`.
**Текущая версия:** `v3.6.0`.
---
@@ -37,6 +37,11 @@
- **📱 Telegram Mini App Dashboard**: Мобильная панель открывается из Telegram, проверяет `initData` на сервере, учитывает safe-area телефона и верхнюю панель Telegram.
- **✏️ Редактирование**: Детальное изменение дат, названий и статусов сессий.
- **🤝 Co-GM и делегирование**: Owner назначает помощников по Telegram ID; co-GM управляет расписанием, но **не может назначать других co-GM**.
- **🌍 Публичные страницы клубов**: Owner и co-GM включают read-only страницу `/club/{slug}` и отдельные ссылки `/s/{sessionId}` только для опубликованных сессий; состав игроков и приватные join-ссылки не показываются.
- **🧑‍🏫 Публичные профили мастеров**: мастер управляет профилем из `/profile`, публикует описание на `/gm/{slug}`, а публичные клубы, игры и каталог ссылаются на профиль без раскрытия platform identifiers.
- **📚 Портфолио завершённых приключений**: Owner и co-GM собирают завершённые сессии в портфолио-игры на странице `/group/{id}/portfolio`, привязывают ссылки на прошедшие сессии и публикуют публичную страницу `/portfolio/{slug}` с обложкой, описанием, системой/форматом и составом мастеров.
- **⭐ Модерируемые отзывы игроков**: участники прошедших сессий могут оставить отзыв на `/portfolio/{slug}/review` с явным согласием на публикацию; мастера модерируют отзывы (`Approved`/`Rejected`/`Hidden`) в редакторе портфолио, и только одобренные отзывы видны публичной странице.
- **🖼 Обложки портфолио**: мастера загружают JPG/PNG/WEBP-обложки в редакторе портфолио; файлы сохраняются в Docker volume `portfolio_covers` и обслуживаются по пути `/portfolio-covers/{storageKey}`; конфигурация пути — `PortfolioCovers__StoragePath` в `compose.yaml`.
- **📋 Шаблоны кампаний**: Вкладка `Шаблоны` отдельно от страницы группы: сохранение типовых параметров и запуск нового batch из шаблона.
- **📦 Bulk-операции для Batch Sessions**:
- обновить общий `title`/`link` у всей пачки;
@@ -124,6 +129,32 @@ docker compose up -d
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
## 📚 Портфолио завершённых приключений
Начиная с **v3.6.0** ГМы могут публиковать завершённые кампании в виде постоянных портфолио-страниц с обложкой, описанием, системой/форматом, составом мастеров и модерируемыми отзывами игроков.
### Возможности
- **Управление портфолио** — в `/group/{id}/portfolio` владелец и co-GM создают портфолио-игры из прошедших сессий, выбирают мастеров, заполняют описание, загружают обложку и публикуют по `public_slug`.
- **Публичная страница `/portfolio/{slug}`** — read-only карточка приключения с обложкой, описанием, составом мастеров (только публичные профили) и одобренными отзывами.
- **Отзывы участников** — на `/portfolio/{slug}/review` аутентифицированные игроки, чьи идентификаторы участвовали в одной из привязанных сессий без пометки GM, отправляют отзыв с явным согласием на публикацию; один отзыв на игрока, повторная отправка запрещена.
- **Модерация отзывов** — на странице редактора портфолио владелец/co-GM видит очередь `Pending` и переводит отзывы в `Approved`, `Rejected` или `Hidden`; только `Approved` отзывы попадают в публичную выдачу.
- **Публикация под требования** — портфолио-игра публикуется только при заполненном slug, описании, обложке, минимум одной завершённой сессии и хотя бы одном мастере группы.
### Хранение обложек
Загруженные обложки хранятся в Docker volume `portfolio_covers` (по умолчанию имя `gmrelay_portfolio_covers`), обслуживаются веб-приложением по пути `/portfolio-covers/{storageKey}` с кешированием `Cache-Control: public, max-age=31536000, immutable`.
В `.env` можно переопределить имя volume:
```env
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
```
В `compose.yaml` это значение пробрасывается в сервис `web` через `volumes.portfolio_covers.name`; путь к каталогу внутри контейнера — `/app/portfolio-covers` (настраивается через `PortfolioCovers__StoragePath`).
Хранилище инкапсулировано интерфейсом `IPortfolioCoverStorage` с реализацией `LocalPortfolioCoverStorage` (файловая система), что оставляет границу для замены на S3-совместимое хранилище без изменения кода портфолио-сервисов.
## 💾 Backup и восстановление
Проект включает автоматический ежедневный backup PostgreSQL через сервис `db-backup` в Docker Compose.
+11 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.0.8
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.7.1
restart: always
depends_on:
db:
@@ -67,11 +67,13 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.0.8
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.7.1
restart: always
depends_on:
db:
condition: service_healthy
bot:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}"
@@ -84,11 +86,13 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.0.8
image: git.codeanddice.ru/toutsu/gmrelay-web:3.7.1
restart: always
depends_on:
db:
condition: service_healthy
bot:
condition: service_healthy
environment:
- "ConnectionStrings__gmrelaydb=Host=db;Port=5432;Database=gmrelay_db;Username=gmrelay;Password=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}"
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
@@ -97,10 +101,12 @@ services:
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
ports:
- "${GMRELAY_WEB_PORT:-8080}:8080"
volumes:
- web_keys:/app/dataprotection-keys
- portfolio_covers:/app/portfolio-covers
networks:
- gmrelay
healthcheck:
@@ -116,6 +122,8 @@ volumes:
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
pgbackups:
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
networks:
gmrelay:
+66 -4
View File
@@ -8,17 +8,20 @@ C4Context
Person(gm, "Game Master", "Creates sessions and manages schedules")
Person(player, "Player", "Joins, leaves, confirms, and receives reminders")
Person(visitor, "Public visitor", "Views published club schedules, sessions, GM profiles, and completed-adventure portfolio pages without private player data")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, and shared scheduling logic")
System(gmrelay, "GM-Relay", "Telegram bot, Discord worker, web dashboard, public club/session/GM profile/portfolio pages, and shared scheduling logic")
System_Ext(telegram, "Telegram Bot API", "Commands, inline keyboards, callback queries, Mini App entry points")
System_Ext(discord, "Discord Gateway and REST API", "Slash commands, button interactions, message edits, ephemeral replies")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities")
SystemDb_Ext(postgres, "PostgreSQL", "Sessions, players, participants, groups, platform identities, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, cover_storage_keys")
Rel(gm, telegram, "Creates and manages sessions")
Rel(gm, discord, "Uses /newsession and /listsessions")
Rel(player, telegram, "Uses inline buttons")
Rel(player, discord, "Uses Join/Leave and RSVP buttons")
Rel(player, gmrelay, "Submits moderated reviews for completed-adventure portfolios")
Rel(visitor, gmrelay, "Views public club, session, GM profile, and portfolio pages")
Rel(telegram, gmrelay, "Updates via long polling")
Rel(discord, gmrelay, "Gateway events and component interactions")
Rel(gmrelay, telegram, "SendMessage, EditMessage, AnswerCallbackQuery")
@@ -34,22 +37,26 @@ C4Container
Person(gm, "Game Master")
Person(player, "Player")
Person(visitor, "Public visitor")
System_Boundary(runtime, "Docker Compose / Aspire runtime") {
Container(bot, "GmRelay.Bot", "Worker Service, .NET 10 AOT", "Telegram long polling, commands, callback routing, reminders")
Container(discordBot, "Discord Gateway Worker", "Внутри GmRelay.Bot", "NetCord Gateway, slash commands, scheduler notifications, button interactions, healthcheck :8082")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, editing and stats")
Container(web, "GmRelay.Web", "Blazor Server", "Dashboard, Mini App pages, public club/session/GM profile/portfolio pages, portfolio review submission and moderation, editing and stats")
Container(shared, "GmRelay.Shared", ".NET library", "Shared domain models, rendering, scheduler, and platform-neutral handlers")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, platform identities")
ContainerDb(db, "PostgreSQL", "Database", "sessions, players, session_participants, game_groups, publication settings, master_profiles, portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews, platform identities")
}
System_Ext(telegram, "Telegram Bot API")
System_Ext(discord, "Discord Gateway and REST API")
SystemDb_Ext(covers, "Portfolio covers volume", "Persistent file store for portfolio cover uploads (LocalPortfolioCoverStorage; S3-compatible replacement boundary)")
Rel(gm, telegram, "Commands")
Rel(gm, discord, "Slash commands")
Rel(player, telegram, "Callback queries")
Rel(player, discord, "Button interactions")
Rel(player, web, "Submits moderated reviews on completed-adventure portfolio pages")
Rel(visitor, web, "Read-only public schedule, sanitized GM profile, and completed-adventure portfolio pages")
Rel(telegram, bot, "GetUpdates")
Rel(discord, discordBot, "Gateway events")
Rel(bot, telegram, "Bot API calls")
@@ -60,6 +67,7 @@ C4Container
Rel(bot, db, "Npgsql + Dapper.AOT")
Rel(discordBot, db, "Npgsql + Dapper")
Rel(web, db, "Npgsql + Dapper")
Rel(web, covers, "Saves, reads, and deletes cover files via IPortfolioCoverStorage")
```
## Level 3: Component - Session Interactions
@@ -121,3 +129,57 @@ C4Component
Rel(telegramMessenger, telegram, "SendMessage/EditMessage + AnswerCallbackQuery")
Rel(healthCheck, discord, "HTTP /health")
```
## Level 3: Component - Completed-Adventure Portfolios
The portfolio subsystem lets GMs curate completed adventures from past sessions, publish a public detail page, and collect moderated player reviews. The cover files live in a persistent volume via the `IPortfolioCoverStorage` boundary; the public schema and contracts are isolated inside `GmRelay.Web.Services.Portfolio` so a future S3-compatible storage adapter can replace `LocalPortfolioCoverStorage` without touching the data layer.
```mermaid
C4Component
title Completed-Adventure Portfolio Subsystem
Person(gm, "Game Master", "Curates completed adventures and moderates reviews")
Person(player, "Player", "Submits one moderated review per completed adventure")
Person(visitor, "Public visitor", "Reads public portfolio pages and approved reviews")
Container_Boundary(web, "GmRelay.Web") {
Component(authorized, "AuthorizedPortfolioService", "Feature service", "Manager authorization, review submission authorization, identity resolution, cover cleanup orchestration")
Component(store, "PortfolioService", "Feature service", "Portfolio CRUD, public reads, review submission, moderation; SQL via Dapper.AOT and advisory locks")
Component(covers, "IPortfolioCoverStorage", "Storage boundary", "LocalPortfolioCoverStorage saves/reads/deletes cover files; S3-compatible replacement boundary")
Component(pages, "PublicPortfolio.razor", "Blazor page", "Renders /portfolio/{slug} and review form for participants")
Component(editor, "PortfolioEditor.razor", "Blazor page", "Renders /group/{id}/portfolio editor, cover upload, and review moderation queue")
}
ContainerDb(db, "PostgreSQL")
ContainerDb_Ext(coversVolume, "portfolio_covers volume", "Persistent file store for cover uploads")
Rel(gm, editor, "Creates, edits, publishes, moderates reviews")
Rel(player, pages, "Submits review")
Rel(visitor, pages, "Reads public portfolio and approved reviews")
Rel(pages, authorized, "GetReviewSubmissionStateForCurrentUserAsync, SubmitReviewForCurrentUserAsync")
Rel(pages, store, "GetPublicPortfolioGamesForClubAsync, GetPublicPortfolioGamesForMasterAsync, GetPublicPortfolioGameBySlugAsync")
Rel(editor, authorized, "GetPortfolioGamesForCurrentUserAsync, CreateDraftForCurrentUserAsync, UpdateDraftForCurrentUserAsync, ReplaceCoverForCurrentUserAsync, SetPublicationForCurrentUserAsync, ModerateReviewForCurrentUserAsync")
Rel(authorized, store, "All manager-gated reads/writes; identity and group authorization")
Rel(authorized, covers, "Save, read, delete cover files")
Rel(authorized, sessionStore, "ISessionStore.IsGroupManagerAsync / ResolveEffectivePlayerIdAsync")
Rel(store, db, "INSERT/UPDATE/SELECT on portfolio_games, portfolio_game_sessions, portfolio_game_masters, portfolio_game_reviews")
Rel(covers, coversVolume, "Filesystem reads/writes")
Rel(editor, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
Rel(pages, covers, "Cover file path via IPortfolioCoverStorage.GetPublicPath")
```
### Portfolio tables (PostgreSQL)
| Table | Purpose |
|---|---|
| `portfolio_games` | Adventure header: `title`, `description`, `system`, `format`, `public_slug`, `cover_storage_key`, `completed_at`, `is_public`, `published_at` |
| `portfolio_game_sessions` | Many-to-many link from `portfolio_games` to past `sessions` used to assemble the adventure |
| `portfolio_game_masters` | Many-to-many link from `portfolio_games` to `players` who are managers of the source group |
| `portfolio_game_reviews` | Player reviews: `author_player_id`, `author_display_name`, `body`, `publication_consent_at`, `moderation_status` (`Pending` / `Approved` / `Rejected` / `Hidden`), `moderated_by_player_id`, `moderated_at` |
### Cover storage boundary
- `IPortfolioCoverStorage` is registered as a DI singleton in `GmRelay.Web`.
- The current implementation `LocalPortfolioCoverStorage` writes under `PortfolioCovers:StoragePath` (default `/app/portfolio-covers`) and is mounted as the Docker volume `portfolio_covers` (configurable via `PORTFOLIO_COVERS_VOLUME_NAME` in `.env`).
- Static files are served by the web container at `/portfolio-covers/{storageKey}` with `Cache-Control: public, max-age=31536000, immutable`.
- Replacing the local filesystem with S3-compatible object storage is a contract-only change: implement `IPortfolioCoverStorage` with the same `SaveAsync` / `GetPublicPath` / `DeleteIfExistsAsync` surface and swap the DI registration in `PortfolioCoverStorageExtensions.AddPortfolioCoverStorage`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,234 @@
# Game Catalog and One-Shot Showcase — Design Spec
> Issue #39: feat: добавить каталог игр и витрину ваншотов
---
## Goal
Build a public `/showcase` page that aggregates published sessions from all clubs into a filterable catalog. Users can browse games by system, format, date, and availability. GM controls whether direct registration from the catalog is allowed. The catalog respects existing seat limits and waitlist logic.
---
## Architecture
Extend the existing public-pages infrastructure (V026) with new session metadata fields, a cross-group query layer in `ISessionStore`, and new Razor pages in `GmRelay.Web`. Bot flows (Telegram + Discord) are updated to collect the new fields during session creation. Fuzzy matching on game system names is performed client-side in the bot UI.
---
## Tech Stack
- .NET 10, Blazor Server, Dapper.AOT, Npgsql
- Existing: `PublicLayout`, `ISessionStore`, `SessionService`, `SessionCapacityRules`
- New: `GameSystem` enum, `ShowcaseFilter` record, `ShowcaseSessionDto`
---
## Data Model
### New Fields on `sessions` (Migration V027)
| Column | Type | Constraints | Description |
|---|---|---|---|
| `is_one_shot` | `BOOLEAN` | `NOT NULL DEFAULT false` | One-shot or campaign |
| `system` | `VARCHAR(50)` | nullable | Game system name (enum value or custom) |
| `description` | `TEXT` | nullable | Short description for card |
| `cover_image_url` | `TEXT` | nullable | Cover image URL |
| `duration_minutes` | `INTEGER` | nullable | Duration in minutes |
| `format` | `VARCHAR(20)` | `CHECK (format IN ('Online','Offline','Hybrid'))`, nullable | Session format |
| `allow_direct_registration` | `BOOLEAN` | `NOT NULL DEFAULT false` | Allow direct registration from showcase |
### `GameSystem` Enum
```csharp
public enum GameSystem
{
Dnd5e, Pathfinder2e, CallOfCthulhu7e, Shadowdark,
OldSchoolEssentials, Dragonbane, BladesInTheDark,
Daggerheart, CyberpunkRed, Mothership, AlienRpg,
WarhammerFantasy, VampireMasquerade5e, StarWarsFfg,
Genesys, SavageWorlds, GURPS, Fate, DungeonWorld,
Ironsworn, Other
}
```
Stored as `VARCHAR(50)` in DB (not native enum) to allow future extension without migration.
### DTOs
```csharp
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration);
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
GameSystem? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter { Today, Tomorrow, ThisWeek, All }
public enum SeatFilter { Available, Waitlist, Any }
```
---
## UI Design
### `/showcase` — Catalog Page
**Layout:**
- Hero with title "Каталог игр"
- Sticky filter bar (horizontal on desktop, collapsible on mobile)
- Responsive grid of session cards (1 col mobile, 2 col tablet, 3 col desktop)
- Pagination (page + pageSize = 12)
**Filters:**
- Date: "Сегодня" | "Завтра" | "На неделю" | "Все"
- Seats: "Есть места" | "Waitlist" | "Любое"
- System: dropdown with all `GameSystem` values
- Type: "Ваншот" | "Кампания" | "Любое"
- Format: "Онлайн" | "Офлайн" | "Гибрид" | "Любое"
**Card Design:**
- Cover image (fallback: colored placeholder with initials)
- Title
- System badge
- Date + time (MSK)
- Duration (e.g. "3 часа")
- Format badge
- Seats indicator: "5/6 мест" | "Waitlist (3)" | "Мест нет"
- Club name (link to `/club/{slug}`)
- Buttons: "Подробнее" → `/s/{id}`, "Записаться" (if `AllowDirectRegistration`)
### `/s/{id}` — Public Session Detail (Updated)
New fields added to existing page:
- Cover image (full-width hero)
- System badge
- Description block
- Duration + format
- GM contact (always visible: Telegram username or Discord tag)
- If `allow_direct_registration`:
- "Записаться" button → Telegram Mini App deeplink or Discord OAuth
- Direct registration into `session_participants` via `SessionCapacityRules`
---
## Backend
### ISessionStore Methods
```csharp
Task<IReadOnlyList<ShowcaseSessionDto>> GetShowcaseSessionsAsync(
ShowcaseFilter filter, int page, int pageSize);
Task<ShowcaseSessionDto?> GetShowcaseSessionAsync(Guid sessionId);
Task<bool> RegisterFromShowcaseAsync(Guid sessionId, PlatformUser user);
```
`GetShowcaseSessionsAsync` query:
- Cross-group (all clubs with `public_schedule_enabled = true`)
- Only `is_public = true` sessions
- `scheduled_at > now() - interval '4 hours'`
- `status <> 'Cancelled'`
- Apply filters in SQL WHERE clause
- Order by `scheduled_at ASC`
- Offset/limit pagination
`RegisterFromShowcaseAsync`:
- Check `allow_direct_registration = true`
- Load session with `FOR UPDATE`
- Count active + waitlisted participants
- Use `SessionCapacityRules.DecideJoinStatus`
- Insert participant with appropriate `registration_status`
- Return true on success, false if full and no waitlist allowed
---
## Bot Integration
### Telegram Bot
During `CreateSessionCommand` flow, after title/link/time input:
1. "Выберите систему:" inline keyboard with `GameSystem` values + "Другое"
2. If text input instead of button: fuzzy match against display names (Levenshtein/Contains/StartsWith)
3. "Описание игры (краткое):" — text input, optional (skip button)
4. "Формат:" inline keyboard — "Онлайн" | "Офлайн" | "Гибрид"
5. "Продолжительность (в часах):" — int input, optional
6. "Обложка (URL или пропустить):" — text input, optional
During `/publish` flow:
- "Разрешить прямую запись из каталога?" — yes/no toggle (default: no)
### Discord Bot
Same flow adapted for Discord interactions:
- Slash command options or button menus for system/format
- Modal input for description, duration, cover URL
- Fuzzy matching on free-text system input
---
## Migration V027
```sql
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online','Offline','Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
```
---
## Testing Strategy
1. **Unit tests:** `SessionCapacityRules` with showcase registration scenarios
2. **Integration tests:** `GetShowcaseSessionsAsync` with each filter combination
3. **UI tests:** `Showcase.razor` rendering with/without cover images, filters applied
4. **Bot tests:** Fuzzy matching algorithm for `GameSystem` resolution
---
## Version Bump
Issue label: `type:feature`**minor bump**
Current: `3.3.0` → Next: `3.4.0`
Files to sync:
- `Directory.Build.props`
- `compose.yaml` (bot, discord, web image tags)
- `.gitea/workflows/deploy.yml` (`VERSION` env)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria (from Issue #39)
- [ ] User can find a published game without accessing a private dashboard
- [ ] Registration does not bypass existing seat/waitlist limits
- [ ] Owner/co-GM controls what appears in the showcase via `is_public` + `allow_direct_registration`
- [ ] Filters work: date, seats, system, type, format
- [ ] GM contact is always visible on public session detail
- [ ] Direct registration respects `SessionCapacityRules`
@@ -0,0 +1,424 @@
# Completed Game Portfolio - Design Spec
> Issue #108: feat: добавить портфолио прошедших игр в витрину мастера
---
## Goal
Add a public portfolio of completed tabletop adventures. A club owner or co-GM can group one or more completed sessions into an adventure card, publish it in selected GM profiles, optionally show it on a public club page, upload a cover image, and moderate player reviews. The existing `/showcase` catalog remains focused on recruitment for upcoming games.
---
## Product Decisions
- A portfolio item is an independent adventure entity, not a flag on one session.
- One adventure can reference multiple completed sessions from the same club.
- Reviews are submitted by authenticated players, not entered manually by a GM.
- A player can review an adventure after being actively registered as a non-GM participant for at least one linked completed session. Waitlisted players are not eligible.
- Each player can submit one review per adventure.
- A review is public only after the player explicitly consents to publication and a club owner or co-GM approves it.
- Public reviews show a display-name snapshot captured at submission time. They never expose platform IDs or account links.
- Adventure visibility in a public GM profile does not depend on club-page visibility.
- The public club page shows its portfolio block only when that club page is enabled.
- Club owners and co-GMs create, edit, publish, and moderate portfolio items. They select one or more GMs whose public profiles display the adventure.
- Creation is available from the club page and through a quick action from a completed session.
- Every published adventure has a dedicated public page at `/portfolio/{slug}`.
- Cover images are uploaded to application-managed storage. The first implementation uses a persistent Docker volume behind a replaceable storage interface so an S3-compatible implementation can be added later without changing pages or database tables.
---
## Architecture
Add a bounded portfolio vertical slice to `GmRelay.Web` and a schema migration in `GmRelay.Bot`. The portfolio tables reference the existing `game_groups`, `players`, and `sessions` tables but do not change the recruitment catalog query or its future-session filters.
Keep portfolio persistence separate from the already large scheduling store. `IPortfolioStore` and `PortfolioService` own portfolio reads, writes, and review submission. `AuthorizedPortfolioService` wraps protected management operations and reuses `ISessionStore.IsGroupManagerAsync` plus the existing current-user identity model for owner/co-GM authorization. Public Razor pages inject `IPortfolioStore` directly for sanitized reads.
Cover storage is isolated behind `IPortfolioCoverStorage`. Pages and services work with generated storage keys and public paths rather than physical file locations. The local implementation stores files in a persistent mounted directory and serves them through a dedicated request path. A future S3 implementation can generate equivalent public paths or signed delivery URLs while preserving the same service contract and database fields.
---
## Data Model
### Migration V029
Create `src/GmRelay.Bot/Migrations/V029__add_completed_game_portfolios_and_reviews.sql`.
### `portfolio_games`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | `UUID` | primary key, generated | Adventure identifier |
| `group_id` | `UUID` | not null, FK to `game_groups(id)` with cascade delete | Owning club |
| `public_slug` | `VARCHAR(160)` | unique case-insensitive when non-null | Public route segment |
| `title` | `VARCHAR(255)` | not null | Adventure title |
| `description` | `TEXT` | nullable for drafts | Public description |
| `cover_storage_key` | `TEXT` | nullable for drafts | Storage-provider-neutral cover key |
| `system` | `VARCHAR(50)` | nullable | Game system |
| `format` | `VARCHAR(20)` | nullable, checked against `Online`, `Offline`, `Hybrid` | Play format |
| `completed_at` | `TIMESTAMPTZ` | not null | Portfolio ordering date |
| `is_public` | `BOOLEAN` | not null, default false | Public visibility |
| `published_at` | `TIMESTAMPTZ` | nullable | First publication timestamp |
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
Constraints and indexes:
```sql
CHECK (NOT is_public OR (
public_slug IS NOT NULL
AND description IS NOT NULL
AND cover_storage_key IS NOT NULL
AND published_at IS NOT NULL
))
```
- Unique index on `lower(public_slug)` when `public_slug IS NOT NULL`.
- Index on `(group_id, completed_at DESC)`.
- Partial public index on `(completed_at DESC)` where `is_public = true`.
Application validation additionally requires at least one linked session, every linked session to be completed with `scheduled_at < now()`, and at least one linked GM before publishing because those requirements span child tables. Publishing locks the parent card, validates both required link sets, then sets `is_public = true` and `published_at = COALESCE(published_at, now())` so `published_at` remains the first-publication timestamp. Link replacement locks the parent card and unpublishes it before replacing required links.
Immediate statement triggers acquire one transaction-level PostgreSQL advisory lock, `pg_advisory_xact_lock(20260530, 108)`, before any invariant-affecting rows are changed: publication transitions and deletes, required-link edits, session deletes and scheduled-date changes, and parent deletes that can cascade into required links. Deferred database constraint triggers validate the same invariant at transaction commit after a card transitions to public, a session link is inserted, deleted, moved, or repointed, or a required master link is deleted or moved. They raise a check-violation error if a published card would commit without both required link sets or with any linked session where `scheduled_at >= now()`. Portfolio and schedule mutations are low volume, so this intentionally global lock establishes one advisory-lock then row-lock protocol, prevents write-skew under the application default `READ COMMITTED` isolation level, and avoids multi-card, card/advisory, and session/advisory deadlocks. PostgreSQL keeps a stale snapshot after waiting under `REPEATABLE READ` or `SERIALIZABLE`, so the guard rejects every triggered portfolio write at those levels; callers must use `READ COMMITTED` for portfolio mutations.
A deferred `sessions.scheduled_at` trigger atomically unpublishes linked public cards when a completed session is finally rescheduled into the future, preserving the first `published_at`. Because deferred row triggers retain their event-time `NEW`, the trigger re-reads the final `sessions.scheduled_at` before acting. It rejects final-future reschedules outside `READ COMMITTED` with `0A000`, because the unpublish pass requires fresh statement snapshots. Under `READ COMMITTED`, it takes row locks for all cards linked to any final-future session in `portfolio_games.id` order, including committed drafts. It then re-acquires the publication advisory lock and unpublishes matching public cards in a guarded update with a fresh statement snapshot. Including drafts prevents a concurrent draft-to-public publication from validating against the pre-reschedule session snapshot and committing afterward. Session mutation paths use advisory-lock then `sessions` then linked `portfolio_games`; normal session-deletion handlers explicitly acquire the mutation lock, lock the target session row, unpublish linked cards in the same transaction, and only then delete the session. The link foreign keys retain `ON DELETE CASCADE`; when the card itself or its owning club is deleted at `READ COMMITTED`, deferred validation sees no surviving published card and remains harmless.
### `portfolio_game_sessions`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `session_id` | `UUID` | not null, unique, FK to `sessions(id)` with cascade delete | Linked completed session |
Primary key: `(portfolio_game_id, session_id)`.
The application accepts only sessions from the adventure's club with `scheduled_at < now()` and rejects cross-club links. The deferred database guard enforces the completed-session condition for every linked session before a public card can commit. A session belongs to at most one portfolio adventure.
### `portfolio_game_masters`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Displayed GM |
Primary key: `(portfolio_game_id, player_id)`.
Add an index on `(player_id, portfolio_game_id)` for public GM profile reads.
### `portfolio_game_reviews`
| Column | Type | Constraints | Description |
|---|---|---|---|
| `id` | `UUID` | primary key, generated | Review identifier |
| `portfolio_game_id` | `UUID` | not null, FK to `portfolio_games(id)` with cascade delete | Adventure |
| `author_player_id` | `UUID` | not null, FK to `players(id)` with cascade delete | Private author reference |
| `author_display_name` | `VARCHAR(255)` | not null | Public snapshot |
| `body` | `TEXT` | not null | Review text |
| `publication_consent_at` | `TIMESTAMPTZ` | not null | Player consent timestamp |
| `moderation_status` | `VARCHAR(20)` | not null, default `Pending`, checked | Moderation state |
| `moderated_by_player_id` | `UUID` | nullable, FK to `players(id)` with set null on delete | Private moderator reference |
| `moderated_at` | `TIMESTAMPTZ` | nullable | Moderation timestamp |
| `created_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
| `updated_at` | `TIMESTAMPTZ` | not null, default now | Audit timestamp |
Constraints and indexes:
```sql
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden'))
UNIQUE (portfolio_game_id, author_player_id)
```
- Author lookup index `ix_portfolio_game_reviews_author` on `(author_player_id)`.
- Partial moderator lookup index `ix_portfolio_game_reviews_moderator` on `(moderated_by_player_id)` where `moderated_by_player_id IS NOT NULL`.
- Partial public index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Approved'` and `publication_consent_at IS NOT NULL`.
- Partial moderation index on `(portfolio_game_id, created_at DESC)` where `moderation_status = 'Pending'`.
---
## Cover Storage
### Contract
Add a small storage abstraction:
```csharp
public interface IPortfolioCoverStorage
{
Task<PortfolioCoverUploadResult> SaveAsync(
Stream content,
string contentType,
CancellationToken cancellationToken = default);
Task DeleteIfExistsAsync(
string storageKey,
CancellationToken cancellationToken = default);
string GetPublicPath(string storageKey);
}
```
`PortfolioCoverUploadResult` carries the generated storage key and normalized content type.
### Local Implementation
- Store covers below a configured `PortfolioCovers:StoragePath`.
- Mount that path from a dedicated Docker volume, `portfolio_covers`.
- Serve files through a dedicated `/portfolio-covers/{storageKey}` route.
- Generate random names. Never use the uploaded filename as the storage key.
- Accept `image/jpeg`, `image/png`, and `image/webp`.
- Limit uploads to 5 MiB.
- Validate file signatures server-side before writing the final file.
- Write to a temporary file, validate, then atomically move into place.
- On successful replacement, delete the old file.
- On database failure after upload, delete the newly uploaded file.
- Deleting an adventure deletes its current cover after successful database deletion.
The storage key remains provider-neutral. A future S3-compatible implementation can replace the local service registration and use the same stored key.
---
## Service Contracts
Add sanitized DTOs to `IPortfolioStore`. Public DTOs must not expose player IDs, group IDs, session IDs, platform identifiers, moderator IDs, physical storage paths, or join links.
Representative contracts:
```csharp
public sealed record PublicPortfolioGame(
string Slug,
string Title,
string Description,
string CoverPath,
string? System,
string? Format,
DateTime CompletedAt,
string? ClubName,
string? ClubSlug,
IReadOnlyList<PublicPortfolioMaster> Masters,
IReadOnlyList<PublicPortfolioReview> Reviews);
public sealed record PublicPortfolioMaster(string Slug, string DisplayName);
public sealed record PublicPortfolioReview(
string AuthorDisplayName,
string Body,
DateTime CreatedAt);
```
Protected DTOs may carry IDs needed for editing and moderation.
### Public Reads
- Load one public adventure by slug for `/portfolio/{slug}`.
- Load public adventures for a public GM profile regardless of club-page visibility.
- Load public adventures for a public club page only when the club page is enabled.
- Return only reviews with explicit consent and `Approved` moderation state.
### Protected Management
Through `AuthorizedPortfolioService`:
- Load draft and published adventure cards for a managed club.
- Load eligible completed sessions for a managed club.
- Create a draft, optionally preselecting one completed session from the quick action.
- Update title, slug, description, system, format, linked sessions, and displayed GMs.
- Upload and replace the cover.
- Publish or unpublish a card.
- Load pending and historical reviews for moderation.
- Approve, reject, or hide a review.
All management operations require the current user to be an owner or co-GM of the owning club.
### Review Submission
An authenticated user can submit a review from `/portfolio/{slug}` only when:
- The adventure is public.
- The user explicitly checks publication consent.
- The user is registered in `session_participants` as a non-GM participant with `registration_status = 'Active'` for at least one linked session.
- The linked session is in the past.
- The user has not submitted a review for this adventure before.
The created review starts in `Pending`. The public page does not display it until moderation changes the status to `Approved`.
---
## User Interface
### Protected Club Page
Extend `GroupDetails.razor` with a completed-adventures section:
- List draft and published portfolio cards.
- Show title, publication state, linked-session count, displayed-GM count, and review moderation count.
- Provide a create action, edit links, and a link to the club's completed-session list.
### Completed Session Quick Action
Add a protected `/group/{groupId}/completed` page that lists past sessions for a managed club. Extend that page and session history with an "Добавить в портфолио" action for a completed session that is not already linked. The action opens the adventure editor with that session preselected.
### Adventure Editor
Add a protected editor page:
- Title and public slug.
- Description.
- System and format.
- Multi-select of completed sessions from the same club.
- Multi-select of displayed GMs.
- Cover upload and replacement.
- Draft save and publish/unpublish actions.
- Review moderation list with approve, reject, and hide actions.
The editor surfaces validation errors without publishing partial data.
### Public GM Profile
Extend `/gm/{slug}` with a "Проведённые приключения" portfolio section. Cards show cover, title, completion date, system, format, and a link to `/portfolio/{slug}`. This list is independent of club-page visibility.
### Public Club Page
Extend `/club/{slug}` with the same compact cards when the public club page is enabled.
### Public Adventure Page
Add `/portfolio/{slug}`:
- Cover hero.
- Title, description, completion date, system, and format.
- Optional public club link.
- Public links to selected GM profiles.
- Approved reviews with display-name snapshots.
- For an eligible authenticated player without an existing review: review form with text area and required publication-consent checkbox.
- For an authenticated ineligible player or a player who already submitted: a short non-sensitive status message.
- For an anonymous visitor: a sign-in prompt instead of the form.
---
## Privacy And Security
- Public DTOs and rendered HTML never expose platform identifiers, player IDs, moderator IDs, linked session IDs, join links, or physical storage paths.
- Cover upload validation uses content signatures, not only the browser-provided MIME type or filename.
- Random storage keys prevent filename guessing and path traversal.
- Review text is rendered as encoded text through normal Razor rendering.
- Authorization is checked in the service layer for every management operation.
- Eligibility is checked in the database-backed service when submitting a review; hiding the form is not treated as authorization.
- The `/showcase` query keeps its current future-session condition and does not include completed adventures.
---
## Docker And Configuration
Add:
```yaml
services:
discord:
depends_on:
bot:
condition: service_healthy
web:
depends_on:
bot:
condition: service_healthy
environment:
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
volumes:
- portfolio_covers:/app/portfolio-covers
volumes:
portfolio_covers:
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
```
Development configuration uses a local directory under the application content root or an explicitly configured path.
The Web Docker image creates `/app/portfolio-covers` and assigns it to `$APP_UID` before switching to the non-root runtime user.
The Telegram bot runs `DbMigrator` synchronously before its health endpoint becomes healthy. Docker Compose therefore starts Discord and Web only after the bot is healthy, using it as the schema-migration gate without duplicating migration ownership. The Aspire AppHost mirrors this readiness gate with database resource name `gmrelaydb`, matching application `ConnectionStrings:gmrelaydb`; it explicitly exposes the bot project resource's non-proxied port `8081` endpoint, attaches `.WithHttpHealthCheck("/health", endpointName: "health")`, and makes its `discord` and `web` project resources wait for both PostgreSQL and the healthy `bot` resource.
---
## Documentation
Update:
- `README.md` with public portfolio capability and local cover-storage configuration.
- `docs/c4-system-context.md` with the portfolio slice and persistent cover volume.
---
## Testing Strategy
Follow TDD for production changes.
### Schema And Contracts
- Migration source-contract tests assert the four new tables, format constraint, publication guard, case-insensitive slug uniqueness, group and GM-profile indexes, card-oriented pending-review index, immediate statement-level mutation locks, completed-session validator, deferred future-reschedule unpublish trigger, advisory-lock then session-row deletion locks, and the AppHost HTTP health gate.
- PostgreSQL integration tests apply migrations V001 through V029 to `postgres:17-alpine` and cover direct invalid link removal, moved links, direct session/player cascades, explicit mutation-lock then session-lock then unpublish then session deletion, delete/reschedule mutation-gate ordering in both first-lock orders, rejection of publication when any linked session is future, automatic unpublish with preserved `published_at` after future reschedule, `past -> future -> past` final-state handling, required-link insertion and final-future reschedule mutation locks before rows, opposing-order batch future reschedules serialized before session rows, existing-link and new-link draft publication/reschedule races, both bounded publish/delete commit orders, concurrent removal of distinct required links without write-skew or deadlock under `READ COMMITTED`, rejection of equivalent `REPEATABLE READ` writes including both draft-delete versus publish commit orders and stale-snapshot final-future reschedules, and parent/card cascade deletion.
- Public DTO reflection/source tests assert that private identifiers and physical storage paths are absent.
- Existing showcase tests continue to assert the future-session catalog boundary.
### Authorization And Eligibility
- Owner and co-GM can manage a club adventure.
- A manager of another club cannot manage it.
- Only registered players from linked past sessions can submit.
- A registered player can submit only once.
- Consent is required.
- A new review is pending and not public.
- Only approved reviews are returned publicly.
### Cover Storage
- Accept valid JPEG, PNG, and WebP signatures.
- Reject unsupported types, mismatched signatures, oversized files, and unsafe names.
- Replacement deletes the old file only after the new file is stored.
- Cleanup removes a newly uploaded file when persistence fails.
### UI Source Contracts
- Protected club and session-history pages expose management entry points.
- Public GM and club pages render compact portfolio sections.
- The public adventure page renders approved reviews and the conditional review form.
- CSS defines responsive portfolio cards, cover hero, editor layout, and review states.
### Regression
- Run the full test suite.
- Run `dotnet build`.
- Run `dotnet format --verify-no-changes`.
- Visually inspect the protected editor and public portfolio pages in the browser.
---
## Version Bump
Issue label: `type:feature` -> minor bump.
Current: `3.5.1` -> Next: `3.6.0`.
Synchronize:
- `Directory.Build.props`
- `compose.yaml` (`bot`, `discord`, and `web` image tags)
- `.gitea/workflows/deploy.yml` (`VERSION`)
- `src/GmRelay.Web/Components/Layout/NavMenu.razor`
---
## Acceptance Criteria Mapping
- [ ] A club owner or co-GM can publish a completed adventure with uploaded cover and description.
- [ ] A portfolio adventure can group one or more completed sessions from the same club.
- [ ] A public portfolio adventure automatically becomes private if any linked completed session is rescheduled into the future, preserving its first-publication timestamp.
- [ ] Selected public GM profiles show portfolio cards independently of club-page visibility.
- [ ] A public club page shows portfolio cards when enabled.
- [ ] `/portfolio/{slug}` shows cover, description, metadata, selected GMs, and approved player reviews.
- [ ] A registered participant of a linked completed session can submit one review with explicit publication consent.
- [ ] Reviews remain non-public until owner/co-GM moderation approves them.
- [ ] Public DTOs and HTML do not expose private identifiers.
- [ ] Uploaded covers survive container replacement through a persistent Docker volume.
- [ ] Storage is isolated behind a replaceable interface for a later S3-compatible implementation.
- [ ] The existing `/showcase` catalog remains focused on upcoming recruitment games.
+9 -5
View File
@@ -2,18 +2,22 @@ var builder = DistributedApplication.CreateBuilder(args);
var postgres = builder.AddPostgres("postgres")
.WithPgAdmin()
.AddDatabase("gmrelay-db");
.AddDatabase("gmrelaydb");
builder.AddProject<Projects.GmRelay_Bot>("bot")
var bot = builder.AddProject<Projects.GmRelay_Bot>("bot")
.WithReference(postgres)
.WaitFor(postgres);
.WaitFor(postgres)
.WithHttpEndpoint(port: 8081, targetPort: 8081, name: "health", isProxied: false)
.WithHttpHealthCheck("/health", endpointName: "health");
builder.AddProject<Projects.GmRelay_DiscordBot>("discord")
.WithReference(postgres)
.WaitFor(postgres);
.WaitFor(postgres)
.WaitFor(bot);
builder.AddProject<Projects.GmRelay_Web>("web")
.WithReference(postgres)
.WaitFor(postgres);
.WaitFor(postgres)
.WaitFor(bot);
builder.Build().Run();
@@ -42,12 +42,13 @@ public sealed class CancelSessionHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() }, transaction);
if (session == null)
{
@@ -89,7 +90,7 @@ public sealed class CancelSessionHandler(
var directRecipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
SELECT p.external_user_id::BIGINT AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -1,308 +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.Rendering;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Telegram.Bot.Types;
using GmRelay.Bot.Infrastructure.Telegram;
using Telegram.Bot.Types.ReplyMarkups;
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
namespace GmRelay.Bot.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient,
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 cancellationToken)
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 botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: дата {timeInput} находится в прошлом и будет пропущена.",
cancellationToken: cancellationToken);
return null;
}
foreach (var timeInput in parseResult.InvalidTimeInputs)
var draft = new WizardDraft
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный формат времени '{timeInput}'. Пропущено.",
cancellationToken: cancellationToken);
}
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)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный лимит мест '{seatLimitInput}'. Укажите целое число больше 0.",
cancellationToken: cancellationToken);
}
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;
}
foreach (var recurringInput in parseResult.InvalidRecurringInputs)
{
await botClient.SendMessage(
message.Chat.Id,
$"⚠️ Предупреждение: некорректный повтор расписания '{recurringInput}'. Укажите число игр 1-52 и шаг 1-365 дней.",
cancellationToken: cancellationToken);
}
/// <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);
if (!parseResult.IsValid)
/// <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 botClient.SendMessage(
chatId: message.Chat.Id,
text: "❌ Не удалось распознать формат. Пожалуйста, используйте шаблон:\n\n/newsession\nНазвание: My Game\nВремя: 15.05.2026 19:30\nВремя: 22.05.2026 19:30\nМест: 4\nСсылка: https://link\nКартинка: https://cover\n\nДля повтора можно указать одну дату и строки:\nИгр: 4\nИнтервал: 7",
cancellationToken: cancellationToken);
await _messenger.EditMessageTextAsync(
draft.ChatId, draft.MessageThreadId, draft.DraftMessageId ?? 0,
$"❌ Не заполнены поля: {missing}", EmptyKeyboard(), ct);
return;
}
var title = parseResult.Title!;
var link = parseResult.Link!;
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 chatId = message.Chat.Id;
var chatTitle = message.Chat.Title ?? "Private Chat";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var commands = BuildCommands(draft, payload);
try
{
await connection.ExecuteAsync(
"""
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@TgId, @Name, @Username, 'Telegram', @TgId::TEXT, @Username)
ON CONFLICT (telegram_id) DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = EXCLUDED.telegram_username,
platform = COALESCE(players.platform, 'Telegram'),
external_user_id = COALESCE(players.external_user_id, EXCLUDED.telegram_id::TEXT),
external_username = COALESCE(players.external_username, EXCLUDED.telegram_username);
""",
new { TgId = gmId, Name = gmName, Username = gmUsername },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
) AS CanManage
FROM game_groups g
WHERE COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) = @ChatId::TEXT
""",
new { ChatId = chatId, GmId = gmId },
transaction);
Guid groupId;
if (existingGroup is null)
foreach (var cmd in commands)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (telegram_chat_id, name, gm_telegram_id, platform, external_group_id)
VALUES (@ChatId, @ChatName, @GmId, 'Telegram', @ChatId::TEXT)
RETURNING id;
""",
new { ChatId = chatId, ChatName = chatTitle, GmId = gmId },
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE COALESCE(p.external_user_id, p.telegram_id::TEXT) = @GmId::TEXT
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, GmId = gmId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
cancellationToken: cancellationToken);
return;
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"UPDATE game_groups SET name = @ChatName WHERE id = @GroupId",
new { ChatName = chatTitle, GroupId = groupId },
transaction);
}
var topicDestination = TelegramTopicRouting.ResolveNewScheduleDestination(
message.Chat.IsForum,
message.MessageThreadId);
var messageThreadId = topicDestination.MessageThreadId;
var topicCreatedByBot = topicDestination.TopicCreatedByBot;
if (topicDestination.ShouldCreateForumTopic)
{
try
{
var topic = await botClient.CreateForumTopic(
chatId: chatId,
name: $"🎲 Игры: {title}",
cancellationToken: cancellationToken);
messageThreadId = topic.MessageThreadId;
}
catch (Telegram.Bot.Exceptions.ApiRequestException ex)
when (TelegramTopicRouting.IsMissingForumTopicRightsError(ex.Message))
{
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(
chatId,
TelegramTopicRouting.MissingForumTopicRightsMessage,
cancellationToken: cancellationToken);
return;
}
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
foreach (var scheduledAt in parseResult.ScheduledTimes.OrderBy(value => value))
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, topic_created_by_bot, max_players)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId, @TopicCreatedByBot, @MaxPlayers)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
Title = title,
Link = link,
ScheduledAt = scheduledAt,
ThreadId = messageThreadId,
TopicCreatedByBot = topicCreatedByBot,
MaxPlayers = parseResult.MaxPlayers,
Status = SessionStatus.Planned
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
}
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Создан батч {BatchId} с {Count} сессиями в группе {GroupId}", batchId, sessions.Count, groupId);
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
var renderResult = TelegramSessionBatchRenderer.Render(view);
Message batchMessage;
if (imageReference is not null && renderResult.Text.Length <= 1024)
{
// Картинка + расписание умещаются в одном Telegram-фото с подписью
try
{
batchMessage = await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}, отправляем текстом", batchId);
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
}
else
{
// Текст слишком длинный для caption — fallback на два сообщения
if (imageReference is not null)
{
try
{
await botClient.SendPhoto(
chatId: chatId,
messageThreadId: messageThreadId,
photo: InputFile.FromString(imageReference),
caption: $"🎲 {System.Net.WebUtility.HtmlEncode(title)}",
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось отправить картинку для батча {BatchId}", batchId);
}
}
batchMessage = await botClient.SendMessage(
chatId: chatId,
messageThreadId: messageThreadId,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
}
await connection.ExecuteAsync(
"UPDATE sessions SET batch_message_id = @MsgId WHERE batch_id = @BatchId",
new { MsgId = batchMessage.MessageId, BatchId = batchId });
try
{
await botClient.DeleteMessage(
chatId: chatId,
messageId: message.MessageId,
cancellationToken: cancellationToken);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Не удалось удалить исходное сообщение {MessageId} в чате {ChatId}", message.MessageId, chatId);
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.LogError(ex, "Ошибка при создании сессии");
await transaction.RollbackAsync(cancellationToken);
await botClient.SendMessage(chatId, "💥 Произошла ошибка базы данных при создании сессии.", cancellationToken: cancellationToken);
_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);
}
}
@@ -41,13 +41,14 @@ public sealed class PromoteWaitlistedPlayerHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
FOR UPDATE
""",
new { command.SessionId, command.TelegramUserId },
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString() },
transaction);
if (session is null)
@@ -150,7 +151,7 @@ public sealed class PromoteWaitlistedPlayerHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -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) { }
}
@@ -1,113 +1,25 @@
using System.Text;
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
using Telegram.Bot.Types;
namespace GmRelay.Bot.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler sharedHandler)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
public Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.telegram_chat_id = @ChatId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = message.From?.Id;
if (!string.IsNullOrWhiteSpace(baseUrl) && senderId.HasValue)
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE telegram_chat_id = @ChatId",
new { ChatId = message.Chat.Id });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)",
new { token, userTelegramId = senderId.Value, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId),
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
return sharedHandler.HandleAsync(command, cancellationToken);
}
}
@@ -1,8 +1,5 @@
using Dapper;
using Npgsql;
using Telegram.Bot;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
@@ -13,138 +10,88 @@ public sealed record DeleteSessionCommand(
long ChatId,
int MessageId);
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler sharedHandler,
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler listSessionsHandler,
IPlatformMessenger messenger,
ILogger<DeleteSessionHandler> logger)
{
public async Task HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
// 1. Fetch session and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
""",
new { command.SessionId, command.TelegramUserId }, transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (session == null)
var scheduleMessage = TelegramPlatformIds.Message(command.ChatId, null, command.MessageId);
var sharedCommand = new GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionCommand(
command.SessionId,
platformUser,
platformGroup,
scheduleMessage);
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия не найдена.", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("owner")),
ct);
return;
}
if (!session.CanManage)
{
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Только owner или co-GM может удалять сессию.", showAlert: true, cancellationToken: ct);
return;
}
// 2. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
// 4. If no sessions are left in a bot-owned forum topic, delete the topic.
if (session.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(session.TopicCreatedByBot, remainingInTopic))
if (result.ThreadId.HasValue &&
TelegramTopicRouting.ShouldDeleteForumTopic(result.TopicCreatedByBot, result.RemainingInTopic))
{
try
{
await bot.DeleteForumTopic(command.ChatId, session.ThreadId.Value, cancellationToken: ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", session.ThreadId.Value, session.BatchId);
await messenger.DeleteThreadAsync(
new PlatformGroup(PlatformKind.Telegram, command.ChatId.ToString(), string.Empty, null, result.ThreadId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)),
ct);
logger.LogInformation("Deleted forum topic {ThreadId} for batch {BatchId} as no sessions remained.", result.ThreadId.Value, result.GroupId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", session.ThreadId.Value);
logger.LogWarning(ex, "Failed to delete forum topic {ThreadId}", result.ThreadId.Value);
}
}
await bot.AnswerCallbackQuery(command.CallbackQueryId, "Сессия удалена!", cancellationToken: ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
// 5. Update the /listsessions message (we delete the message or edit it to remove the button)
// A simple way is to re-render the list:
await using var readConnection = await dataSource.OpenConnectionAsync(ct);
var sessions = await readConnection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = command.ChatId,
command.TelegramUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
// 5. Update the /listsessions message
var listCommand = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(platformGroup, platformUser);
var listResult = await listSessionsHandler.HandleAsync(listCommand, ct);
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
if (listResult.Sessions.Count == 0)
{
try { await bot.EditMessageText(command.ChatId, command.MessageId, "📭 В этой группе нет предстоящих игр.", cancellationToken: ct); } catch { }
try
{
await messenger.UpdateGroupMessageAsync(
scheduleMessage,
"📭 В этой группе нет предстоящих игр.",
[],
ct);
}
catch { }
return;
}
var renderResult = SessionListMessageRenderer.Render(sessionsList);
var text = SessionListMessageRenderer.RenderText(listResult.Sessions);
var actions = listResult.CanManage ? SessionListMessageRenderer.RenderActions(listResult.Sessions) : [];
try
{
await bot.EditMessageText(
command.ChatId,
command.MessageId,
renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: ct);
await messenger.UpdateGroupMessageAsync(scheduleMessage, text, actions, ct);
}
catch (Exception ex)
{
@@ -1,114 +1,37 @@
using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
using Telegram.Bot;
using GmRelay.Shared.Platform;
using Telegram.Bot.Types;
using Telegram.Bot.Types.ReplyMarkups;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
internal static class SessionListMessageRenderer
{
public static (string Text, InlineKeyboardMarkup? Markup) Render(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
var canManage = sessions.Count > 0 && sessions.First().CanManage;
if (!canManage)
{
return (text, null);
}
var buttons = new List<InlineKeyboardButton[]>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"❌ {dateTitle}", $"cancel_session:{session.Id}"),
InlineKeyboardButton.WithCallbackData($"⏰ {dateTitle}", $"reschedule_session:{session.Id}")
]);
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"⬆️ Из ожидания {dateTitle}", $"promote_waitlist:{session.Id}")
]);
}
buttons.Add(
[
InlineKeyboardButton.WithCallbackData($"🗑 Удалить {dateTitle}", $"delete_session:{session.Id}")
]);
}
return (text, new InlineKeyboardMarkup(buttons));
}
}
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient botClient)
GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler sharedHandler,
IPlatformMessenger messenger)
{
public async Task HandleAsync(Message message, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var command = new GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsCommand(
new PlatformGroup(
PlatformKind.Telegram,
message.Chat.Id.ToString(),
message.Chat.Title ?? "Private Chat",
message.MessageThreadId?.ToString()),
new PlatformUser(
PlatformKind.Telegram,
message.From?.Id.ToString() ?? string.Empty,
message.From?.FirstName ?? string.Empty,
message.From?.Username));
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @TelegramUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
ChatId = message.Chat.Id,
TelegramUserId = message.From?.Id,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var result = await sharedHandler.HandleAsync(command, cancellationToken);
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
if (result.Sessions.Count == 0)
{
await botClient.SendMessage(
chatId: message.Chat.Id,
text: "📭 В этой группе нет предстоящих игр.",
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, "📭 В этой группе нет предстоящих игр.", cancellationToken);
return;
}
var renderResult = SessionListMessageRenderer.Render(sessionsList);
var text = SessionListMessageRenderer.RenderText(result.Sessions);
var actions = result.CanManage ? SessionListMessageRenderer.RenderActions(result.Sessions) : [];
await botClient.SendMessage(
chatId: message.Chat.Id,
text: renderResult.Text,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: renderResult.Markup,
cancellationToken: cancellationToken);
await messenger.SendGroupMessageAsync(command.Group, text, actions, cancellationToken);
}
}
@@ -0,0 +1,63 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Features.Sessions.ListSessions;
namespace GmRelay.Bot.Features.Sessions.ListSessions;
internal static class SessionListMessageRenderer
{
public static string RenderText(IReadOnlyList<SessionListItemDto> sessions)
{
var text = "📅 <b>Ближайшие игры:</b>\n\n";
foreach (var session in sessions)
{
var seats = session.MaxPlayers.HasValue
? $"{session.PlayerCount}/{session.MaxPlayers.Value}"
: session.PlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
var waitlist = session.WaitlistCount > 0 ? $", ожидание: {session.WaitlistCount}" : string.Empty;
text += $"🔹 <b>{session.ScheduledAt.FormatMoscow()}</b> — {System.Net.WebUtility.HtmlEncode(session.Title)} (Места: {seats}{waitlist})\n";
}
return text;
}
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
{
if (sessions.Count == 0 || !sessions.First().CanManage)
{
return [];
}
var actions = new List<PlatformMessageAction>();
foreach (var session in sessions)
{
var dateTitle = session.ScheduledAt.FormatMoscowShort();
actions.Add(new PlatformMessageAction(
$"cancel_session:{session.Id}",
$"❌ {dateTitle}",
$"cancel_session:{session.Id}"));
actions.Add(new PlatformMessageAction(
$"reschedule_session:{session.Id}",
$"⏰ {dateTitle}",
$"reschedule_session:{session.Id}"));
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
{
actions.Add(new PlatformMessageAction(
$"promote_waitlist:{session.Id}",
$"⬆️ Из ожидания {dateTitle}",
$"promote_waitlist:{session.Id}"));
}
actions.Add(new PlatformMessageAction(
$"delete_session:{session.Id}",
$"🗑 Удалить {dateTitle}",
$"delete_session:{session.Id}"));
}
return actions;
}
}
@@ -12,241 +12,156 @@ using GmRelay.Bot.Infrastructure.Telegram;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
// ── DTOs ─────────────────────────────────────────────────────────────
internal sealed record AwaitingProposalDto(
Guid Id, Guid SessionId, string Title, DateTime CurrentScheduledAt,
Guid BatchId, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode);
// ── Handler ──────────────────────────────────────────────────────────
/// <summary>
/// Handles text input from the GM who has an AwaitingTime proposal.
/// Parses reschedule options with a voting deadline, creates a voting message,
/// and tags all participants.
/// If no participants are registered, reschedules immediately.
/// Telegram adapter for reschedule time input.
/// Delegates core logic to the shared handler, then performs Telegram-specific
/// message sending, DM notifications, vote_message_id storage, and cleanup.
/// </summary>
public sealed class HandleRescheduleTimeInputHandler(
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler sharedHandler,
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
IPlatformMessenger messenger,
DirectSessionNotificationSender directSender,
ILogger<HandleRescheduleTimeInputHandler> logger)
{
/// <summary>
/// Attempts to handle a text message as reschedule time input.
/// Returns true if it was handled (i.e. user had an AwaitingTime proposal).
/// </summary>
public async Task<bool> TryHandleAsync(Message message, CancellationToken ct)
{
if (message.From is null || string.IsNullOrWhiteSpace(message.Text))
return false;
var gmTelegramId = message.From.Id;
var chatId = message.Chat.Id;
var text = message.Text.Trim();
var command = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputCommand(
new PlatformUser(
PlatformKind.Telegram,
message.From.Id.ToString(),
message.From.FirstName + (string.IsNullOrEmpty(message.From.LastName) ? "" : $" {message.From.LastName}"),
message.From.Username),
TelegramPlatformIds.Group(message.Chat.Id, message.MessageThreadId, message.Chat.Title),
message.Text.Trim());
await using var connection = await dataSource.OpenConnectionAsync(ct);
// 1. Check if this GM has an AwaitingTime proposal in this chat
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by = @GmId
AND rp.status = 'AwaitingTime'
AND g.telegram_chat_id = @ChatId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.telegram_id = @GmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { GmId = gmTelegramId, ChatId = chatId });
if (proposal is null)
var result = await sharedHandler.HandleAsync(command, ct);
if (!result.Handled)
return false;
// 2. Parse voting input
if (!RescheduleVotingInput.TryParse(text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
if (!string.IsNullOrEmpty(result.ReplyText) && !result.IsRescheduledImmediately)
{
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"⚠️ {parseError}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>",
command.Group,
$"""⚠️ {result.ReplyText}\n\nИспользуйте формат:\n<code>25.04.2026 19:30\n26.04.2026 18:00\nДедлайн: 25.04.2026 12:00</code>""",
ct);
return true;
}
// 3. Load participants (non-GM) signed up for this session
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
// 4. If no participants — reschedule immediately
if (participants.Count == 0)
if (result.IsRescheduledImmediately)
{
await RescheduleImmediately(connection, proposal, votingInput.Options[0], chatId, ct);
await TryDeleteMessage(chatId, message.MessageId, ct);
if (result.UpdatedView is not null && result.BatchMessageId.HasValue)
{
await TryUpdateBatchMessage(
command.Group,
result.UpdatedView,
TelegramPlatformIds.Message(message.Chat.Id, message.MessageThreadId, result.BatchMessageId.Value),
ct);
}
await messenger.SendGroupMessageAsync(command.Group, result.ReplyText!, ct);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
// 5. Create voting message
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @ChatId
WHERE id = @Id
""",
new { votingInput.Deadline, ChatId = chatId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
// Voting mode
var voteText = BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
votingInput.Deadline,
options,
participants,
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt!.Value,
result.Options,
result.Participants,
[]);
var keyboard = BuildVotingKeyboard(options);
var keyboard = BuildVotingKeyboard(result.Options);
var voteMsg = await bot.SendMessage(
chatId: chatId,
messageThreadId: proposal.ThreadId,
chatId: message.Chat.Id,
messageThreadId: message.MessageThreadId,
text: voteText,
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html,
replyMarkup: keyboard,
cancellationToken: ct);
var mode = SessionNotificationModeExtensions.FromDatabaseValue(proposal.NotificationMode);
var mode = await GetNotificationModeAsync(result.ProposalId!.Value, ct);
if (mode.ShouldSendDirectMessages())
{
var optionsText = string.Join(
"\n",
options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
result.Options.Select(option => $"{option.DisplayOrder}. <b>{option.ProposedAt.FormatMoscow()}</b> (МСК)"));
var directText = $"""
🔄 <b>Голосование за перенос сессии</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(proposal.Title)}</b>
📅 Текущее время: <b>{proposal.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
📌 <b>{System.Net.WebUtility.HtmlEncode(result.Title)}</b>
📅 Текущее время: <b>{result.CurrentScheduledAt.FormatMoscow()}</b> (МСК)
🗳 Варианты:
{optionsText}
⏳ Дедлайн: <b>{votingInput.Deadline.FormatMoscow()}</b> (МСК)
⏳ Дедлайн: <b>{result.VotingDeadlineAt.Value.FormatMoscow()}</b> (МСК)
Проголосуйте кнопкой в групповом сообщении.
""";
await directSender.SendAsync(
participants.Select(p => new DirectNotificationRecipient(
result.Participants.Select(p => new DirectNotificationRecipient(
p.TelegramId,
p.DisplayName)),
directText,
"reschedule-vote",
proposal.SessionId,
result.ProposalId.Value,
ct);
}
// Store vote message ID
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET vote_message_id = @MsgId WHERE id = @Id",
new { MsgId = voteMsg.MessageId, Id = proposal.Id });
new { MsgId = voteMsg.MessageId, Id = result.ProposalId.Value });
logger.LogInformation(
"Reschedule voting started for session {SessionId}, proposal {ProposalId}, options {OptionCount}, deadline {Deadline}",
proposal.SessionId,
proposal.Id,
options.Count,
votingInput.Deadline);
// Delete GM's time input message
await TryDeleteMessage(chatId, message.MessageId, ct);
result.ProposalId.Value,
result.ProposalId.Value,
result.Options.Count,
result.VotingDeadlineAt.Value);
await TryDeleteMessage(message.Chat.Id, message.MessageId, ct);
return true;
}
private async Task RescheduleImmediately(
NpgsqlConnection connection, AwaitingProposalDto proposal,
DateTimeOffset newTime, long chatId, CancellationToken ct)
private async Task<SessionNotificationMode> GetNotificationModeAsync(Guid proposalId, CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
await using var connection = await dataSource.OpenConnectionAsync(ct);
var raw = await connection.QuerySingleOrDefaultAsync<string?>(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
SELECT s.notification_mode
FROM sessions s
JOIN reschedule_proposals rp ON rp.session_id = s.id
WHERE rp.id = @Id
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
new { Id = proposalId });
return SessionNotificationModeExtensions.FromDatabaseValue(raw ?? string.Empty);
}
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
await messenger.SendGroupMessageAsync(
TelegramPlatformIds.Group(chatId, proposal.ThreadId),
$"✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>",
ct);
// Re-render batch message with updated time
await TryUpdateBatchMessage(proposal, ct);
logger.LogInformation("Session {SessionId} rescheduled immediately (no participants)", proposal.SessionId);
private async Task TryUpdateBatchMessage(
PlatformGroup group,
SessionBatchViewModel view,
PlatformMessageRef scheduleMessage,
CancellationToken ct)
{
try
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(group, view, scheduleMessage),
ct);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule");
}
}
internal static string BuildVotingMessage(
@@ -268,7 +183,7 @@ public sealed class HandleRescheduleTimeInputHandler(
var lines = new List<string>
{
$"🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>",
$"""🔄 <b>Перенос сессии «{System.Net.WebUtility.HtmlEncode(title)}»</b>""",
"",
$"📅 Текущее время: <b>{currentTime.FormatMoscow()}</b> (МСК)",
$"⏳ Дедлайн: <b>{deadline.FormatMoscow()}</b> (МСК)",
@@ -349,52 +264,6 @@ public sealed class HandleRescheduleTimeInputHandler(
"dd.MM HH:mm",
System.Globalization.CultureInfo.InvariantCulture);
private async Task TryUpdateBatchMessage(AwaitingProposalDto proposal, CancellationToken ct)
{
try
{
await using var conn = await dataSource.OpenConnectionAsync(ct);
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
if (proposal.BatchMessageId.HasValue)
{
var view = SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
TelegramPlatformIds.Group(proposal.TelegramChatId, proposal.ThreadId),
view,
TelegramPlatformIds.Message(proposal.TelegramChatId, proposal.ThreadId, proposal.BatchMessageId.Value)),
ct);
}
else
{
logger.LogWarning("No batch_message_id stored for session {SessionId}, cannot edit batch message in-place", proposal.SessionId);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update batch message after immediate reschedule for session {SessionId}", proposal.SessionId);
}
}
private async Task TryDeleteMessage(long chatId, int messageId, CancellationToken ct)
{
try
@@ -1,8 +1,7 @@
using Dapper;
using GmRelay.Bot.Infrastructure.Telegram;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using Telegram.Bot;
namespace GmRelay.Bot.Features.Sessions.RescheduleSession;
@@ -15,130 +14,49 @@ public sealed record HandleRescheduleVoteCommand(
int MessageId);
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
ITelegramBotClient bot,
IPlatformMessenger messenger,
ILogger<HandleRescheduleVoteHandler> logger)
{
public async Task HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var platformUser = new PlatformUser(
PlatformKind.Telegram,
command.TelegramUserId.ToString(),
string.Empty,
null);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
var platformGroup = new PlatformGroup(
PlatformKind.Telegram,
command.ChatId.ToString(),
string.Empty);
if (proposal is null)
var sharedCommand = new GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteCommand(
command.OptionId,
platformUser,
platformGroup,
command.CallbackQueryId,
TelegramPlatformIds.Message(command.ChatId, null, command.MessageId));
var result = await sharedHandler.HandleAsync(sharedCommand, ct);
if (!result.Success)
{
await AnswerAsync(command.CallbackQueryId, "Голосование уже завершено или не найдено.", ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!, result.ReplyText!.Contains("дедлайн")),
ct);
return;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
await AnswerAsync(command.CallbackQueryId, "Дедлайн уже прошёл. Результаты скоро будут применены.", ct, showAlert: true);
return;
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.telegram_id = @TelegramUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, command.TelegramUserId, Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
{
await AnswerAsync(command.CallbackQueryId, "Вы не являетесь участником этой сессии.", ct);
return;
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
var voteText = HandleRescheduleTimeInputHandler.BuildVotingMessage(
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
options,
participants,
votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(options);
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var keyboard = HandleRescheduleTimeInputHandler.BuildVotingKeyboard(result.Options);
try
{
@@ -152,12 +70,11 @@ public sealed class HandleRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update reschedule vote message for proposal {ProposalId}", result.ProposalId);
}
await AnswerAsync(command.CallbackQueryId, "Ваш голос учтён. До дедлайна его можно изменить.", ct);
await messenger.AnswerInteractionAsync(
new PlatformInteractionReply(command.CallbackQueryId, result.ReplyText!),
ct);
}
private Task AnswerAsync(string callbackQueryId, string text, CancellationToken ct, bool showAlert = false) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(callbackQueryId, text, showAlert), ct);
}
@@ -45,12 +45,13 @@ public sealed class InitiateRescheduleHandler(
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.telegram_id = @TelegramUserId
AND p.platform = 'Telegram'
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId AND s.status != @Cancelled
""",
new { command.SessionId, command.TelegramUserId, Cancelled = SessionStatus.Cancelled });
new { command.SessionId, ExternalUserId = command.TelegramUserId.ToString(), Cancelled = SessionStatus.Cancelled });
if (session is null)
{
@@ -83,10 +84,10 @@ public sealed class InitiateRescheduleHandler(
// 3. Create proposal in AwaitingTime status
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_proposals (session_id, proposed_by, source_platform, status)
VALUES (@SessionId, @GmId, 'Telegram', 'AwaitingTime')
INSERT INTO reschedule_proposals (session_id, proposed_by_external_user_id, source_platform, status)
VALUES (@SessionId, @ProposedBy, 'Telegram', 'AwaitingTime')
""",
new { command.SessionId, GmId = command.TelegramUserId });
new { command.SessionId, ProposedBy = command.TelegramUserId.ToString() });
logger.LogInformation("Reschedule initiated for session {SessionId} by GM {GmId}", command.SessionId, command.TelegramUserId);
@@ -79,7 +79,7 @@ public sealed class RescheduleVotingDeadlineService(
"""
SELECT rp.vote_message_id AS VoteMessageId,
s.batch_message_id AS BatchMessageId,
g.telegram_chat_id AS TelegramChatId,
g.external_group_id::BIGINT AS TelegramChatId,
s.thread_id AS ThreadId
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
@@ -169,7 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -95,6 +95,68 @@ public sealed class TelegramPlatformMessenger(
cancellationToken: ct);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(group.Platform);
await bot.SendMessage(
chatId: ParseLong(group.ExternalGroupId),
messageThreadId: ParseNullableInt(group.ExternalThreadId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
await bot.EditMessageText(
chatId: ParseLong(messageRef.ExternalGroupId),
messageId: ParseInt(messageRef.ExternalMessageId),
text: htmlText,
parseMode: ParseMode.Html,
replyMarkup: BuildActionsMarkup(actions),
cancellationToken: ct);
}
public async Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
EnsureTelegram(group.Platform);
var topic = await bot.CreateForumTopic(
chatId: ParseLong(group.ExternalGroupId),
name: title,
cancellationToken: ct);
return new PlatformMessageRef(
PlatformKind.Telegram,
group.ExternalGroupId,
topic.MessageThreadId.ToString(CultureInfo.InvariantCulture),
string.Empty);
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct)
{
EnsureTelegram(group.Platform);
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
{
return Task.CompletedTask;
}
return bot.DeleteForumTopic(
ParseLong(group.ExternalGroupId),
ParseInt(group.ExternalThreadId),
cancellationToken: ct);
}
public Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
EnsureTelegram(messageRef.Platform);
return bot.DeleteMessage(
ParseLong(messageRef.ExternalGroupId),
ParseInt(messageRef.ExternalMessageId),
cancellationToken: ct);
}
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
EnsureTelegram(message.Recipient.Platform);
@@ -2,9 +2,14 @@
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;
using BotRescheduleVoteHandler = GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler;
using GmRelay.Bot.Features.Sessions.ExportCalendar;
using GmRelay.Bot.Features.Sessions.RescheduleSession;
using Telegram.Bot;
@@ -20,7 +25,7 @@ namespace GmRelay.Bot.Infrastructure.Telegram;
/// </summary>
public sealed class UpdateRouter(
HandleRsvpHandler rsvpHandler,
CreateSessionHandler createSessionHandler,
BotCreateSessionHandler createSessionHandler,
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
PromoteWaitlistedPlayerHandler promoteWaitlistedPlayerHandler,
@@ -29,14 +34,46 @@ public sealed class UpdateRouter(
ListSessionsHandler listSessionsHandler,
ExportCalendarHandler exportCalendarHandler,
InitiateRescheduleHandler initiateRescheduleHandler,
HandleRescheduleTimeInputHandler rescheduleTimeInputHandler,
HandleRescheduleVoteHandler rescheduleVoteHandler,
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 }:
@@ -60,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)
@@ -210,7 +344,7 @@ public sealed class UpdateRouter(
break;
case "/newsession":
await createSessionHandler.HandleAsync(message, ct);
await HandleNewSessionCommandAsync(message, ct);
break;
case "/listsessions":
@@ -253,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"];
@@ -273,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,14 @@
-- =============================================================
-- V023: Make legacy Telegram columns nullable for multi-platform
-- =============================================================
-- Scope: Allow Discord (and future platforms) to create players
-- and game_groups without legacy telegram_* values.
-- Existing Telegram data was backfilled in V016.
-- =============================================================
ALTER TABLE game_groups
ALTER COLUMN telegram_chat_id DROP NOT NULL,
ALTER COLUMN gm_telegram_id DROP NOT NULL;
ALTER TABLE players
ALTER COLUMN telegram_id DROP NOT NULL;
@@ -0,0 +1,41 @@
-- =============================================================
-- V024: Deprecate legacy Telegram-specific columns
-- =============================================================
-- Scope: Complete platform migration by backfilling any remaining
-- external_* gaps and officially deprecating telegram_* columns.
-- No columns are dropped — rollback-safe.
-- =============================================================
-- 1. Backfill players platform identity (safeguard for any rows missed in V016)
UPDATE players
SET platform = 'Telegram',
external_user_id = telegram_id::TEXT,
external_username = telegram_username
WHERE platform IS NULL;
-- 2. Backfill game_groups platform identity (safeguard for any rows missed in V016)
UPDATE game_groups
SET platform = 'Telegram',
external_group_id = telegram_chat_id::TEXT
WHERE platform IS NULL;
-- 3. Add platform identity to calendar_subscriptions
ALTER TABLE calendar_subscriptions
ADD COLUMN user_platform VARCHAR(50),
ADD COLUMN user_external_id VARCHAR(255);
UPDATE calendar_subscriptions
SET user_external_id = user_telegram_id::TEXT,
user_platform = 'Telegram'
WHERE user_platform IS NULL;
-- 4. Migrate calendar subscription index
DROP INDEX IF EXISTS ix_calendar_subscriptions_user_telegram_id;
CREATE INDEX ix_calendar_subscriptions_user_external_id ON calendar_subscriptions (user_external_id);
-- 5. Deprecation comments on legacy columns
COMMENT ON COLUMN players.telegram_id IS 'DEPRECATED: use platform + external_user_id';
COMMENT ON COLUMN players.telegram_username IS 'DEPRECATED: use external_username';
COMMENT ON COLUMN game_groups.telegram_chat_id IS 'DEPRECATED: use platform + external_group_id';
COMMENT ON COLUMN game_groups.gm_telegram_id IS 'DEPRECATED: group ownership is tracked in group_managers';
COMMENT ON COLUMN calendar_subscriptions.user_telegram_id IS 'DEPRECATED: use user_platform + user_external_id';
@@ -0,0 +1,11 @@
-- =============================================================
-- V025: Backfill proposed_by_external_user_id for Telegram proposals
-- =============================================================
-- Scope: Ensure all reschedule_proposals have proposed_by_external_user_id
-- populated so that InitiateRescheduleHandler can stop writing proposed_by.
-- =============================================================
UPDATE reschedule_proposals
SET proposed_by_external_user_id = proposed_by::TEXT
WHERE proposed_by_external_user_id IS NULL
AND proposed_by IS NOT NULL;
@@ -0,0 +1,17 @@
-- Public club pages and read-only schedule publication controls.
ALTER TABLE game_groups
ADD COLUMN public_slug VARCHAR(120),
ADD COLUMN public_schedule_enabled BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN public_schedule_updated_at TIMESTAMPTZ;
ALTER TABLE sessions
ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT false;
CREATE UNIQUE INDEX ux_game_groups_public_slug
ON game_groups (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_sessions_public_schedule
ON sessions (group_id, scheduled_at)
WHERE is_public = true AND status <> 'Cancelled';
@@ -0,0 +1,14 @@
-- Showcase fields for game catalog / public session browsing.
ALTER TABLE sessions
ADD COLUMN is_one_shot BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN system VARCHAR(50),
ADD COLUMN description TEXT,
ADD COLUMN cover_image_url TEXT,
ADD COLUMN duration_minutes INTEGER,
ADD COLUMN format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
ADD COLUMN allow_direct_registration BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX ix_sessions_showcase
ON sessions (scheduled_at, system, is_one_shot, format)
WHERE is_public = true AND status <> 'Cancelled';
@@ -0,0 +1,20 @@
-- Public GM profiles for catalog and club trust pages.
CREATE TABLE master_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
player_id UUID NOT NULL UNIQUE REFERENCES players(id) ON DELETE CASCADE,
public_slug VARCHAR(120),
is_public BOOLEAN NOT NULL DEFAULT false,
display_name VARCHAR(255) NOT NULL,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ux_master_profiles_public_slug
ON master_profiles (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_master_profiles_public
ON master_profiles (lower(public_slug))
WHERE is_public = true AND public_slug IS NOT NULL;
@@ -0,0 +1,261 @@
-- Completed adventure portfolio cards with linked sessions, masters, and moderated reviews.
CREATE TABLE portfolio_games (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES game_groups(id) ON DELETE CASCADE,
public_slug VARCHAR(160),
title VARCHAR(255) NOT NULL,
description TEXT,
cover_storage_key TEXT,
system VARCHAR(50),
format VARCHAR(20) CHECK (format IN ('Online', 'Offline', 'Hybrid')),
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
is_public BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CHECK (
NOT is_public
OR (
public_slug IS NOT NULL
AND description IS NOT NULL
AND cover_storage_key IS NOT NULL
AND published_at IS NOT NULL
)
)
);
CREATE UNIQUE INDEX ux_portfolio_games_public_slug
ON portfolio_games (lower(public_slug))
WHERE public_slug IS NOT NULL;
CREATE INDEX ix_portfolio_games_group
ON portfolio_games (group_id, completed_at DESC);
CREATE INDEX ix_portfolio_games_public
ON portfolio_games (completed_at DESC)
WHERE is_public = true;
CREATE TABLE portfolio_game_sessions (
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
PRIMARY KEY (portfolio_game_id, session_id),
UNIQUE (session_id)
);
CREATE TABLE portfolio_game_masters (
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
PRIMARY KEY (portfolio_game_id, player_id)
);
CREATE INDEX ix_portfolio_game_masters_player
ON portfolio_game_masters (player_id, portfolio_game_id);
CREATE FUNCTION lock_portfolio_publication_mutation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108);
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_portfolio_games_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE OF is_public ON portfolio_games
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_portfolio_game_sessions_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_sessions
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_portfolio_game_masters_lock_publication_mutation
BEFORE INSERT OR DELETE OR UPDATE ON portfolio_game_masters
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_sessions_lock_portfolio_publication_mutation
BEFORE DELETE OR UPDATE OF scheduled_at ON sessions
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_game_groups_lock_portfolio_publication_mutation_before_delete
BEFORE DELETE ON game_groups
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE TRIGGER trg_players_lock_portfolio_publication_mutation_before_delete
BEFORE DELETE ON players
FOR EACH STATEMENT
EXECUTE FUNCTION lock_portfolio_publication_mutation();
CREATE FUNCTION validate_public_portfolio_game_required_links()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
target_portfolio_game_id UUID;
target_portfolio_game_ids UUID[];
BEGIN
PERFORM pg_advisory_xact_lock(20260530, 108);
IF TG_TABLE_NAME = 'portfolio_games' THEN
target_portfolio_game_ids := ARRAY[NEW.id];
ELSIF TG_OP = 'DELETE' THEN
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id];
ELSIF TG_OP = 'INSERT' THEN
target_portfolio_game_ids := ARRAY[NEW.portfolio_game_id];
ELSE
target_portfolio_game_ids := ARRAY[OLD.portfolio_game_id, NEW.portfolio_game_id];
END IF;
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio publication validation requires read committed isolation'
USING ERRCODE = '0A000';
END IF;
SELECT pg.id
INTO target_portfolio_game_id
FROM portfolio_games pg
WHERE pg.id = ANY(target_portfolio_game_ids)
AND pg.is_public = true
AND (
NOT EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
)
OR EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
OR NOT EXISTS (
SELECT 1
FROM portfolio_game_masters pgm
WHERE pgm.portfolio_game_id = pg.id
)
)
LIMIT 1;
IF target_portfolio_game_id IS NOT NULL THEN
RAISE EXCEPTION
'published portfolio game % must have at least one linked session and at least one linked master',
target_portfolio_game_id
USING ERRCODE = '23514';
END IF;
RETURN NULL;
END;
$$;
CREATE FUNCTION unpublish_public_portfolio_games_for_future_session()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
final_scheduled_at TIMESTAMPTZ;
BEGIN
SELECT s.scheduled_at
INTO final_scheduled_at
FROM sessions s
WHERE s.id = NEW.id;
IF final_scheduled_at >= now() THEN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION
'portfolio future reschedule requires read committed isolation'
USING ERRCODE = '0A000';
END IF;
PERFORM pg.id
FROM portfolio_games pg
WHERE EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
)
ORDER BY pg.id
FOR UPDATE OF pg;
PERFORM pg_advisory_xact_lock(20260530, 108);
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
WHERE pg.is_public = true
AND EXISTS (
SELECT 1
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
WHERE pgs.portfolio_game_id = pg.id
AND s.scheduled_at >= now()
);
END IF;
RETURN NULL;
END;
$$;
CREATE CONSTRAINT TRIGGER trg_sessions_unpublish_public_portfolio_games_for_future_reschedule
AFTER UPDATE OF scheduled_at ON sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION unpublish_public_portfolio_games_for_future_session();
CREATE CONSTRAINT TRIGGER trg_portfolio_games_validate_required_links
AFTER INSERT OR UPDATE OF is_public ON portfolio_games
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_sessions_validate_required_links
AFTER INSERT OR DELETE OR UPDATE OF portfolio_game_id, session_id ON portfolio_game_sessions
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE CONSTRAINT TRIGGER trg_portfolio_game_masters_validate_required_links
AFTER DELETE OR UPDATE OF portfolio_game_id ON portfolio_game_masters
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW
EXECUTE FUNCTION validate_public_portfolio_game_required_links();
CREATE TABLE portfolio_game_reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_game_id UUID NOT NULL REFERENCES portfolio_games(id) ON DELETE CASCADE,
author_player_id UUID NOT NULL REFERENCES players(id) ON DELETE CASCADE,
author_display_name VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
publication_consent_at TIMESTAMPTZ NOT NULL,
moderation_status VARCHAR(20) NOT NULL DEFAULT 'Pending'
CHECK (moderation_status IN ('Pending', 'Approved', 'Rejected', 'Hidden')),
moderated_by_player_id UUID REFERENCES players(id) ON DELETE SET NULL,
moderated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (portfolio_game_id, author_player_id)
);
CREATE INDEX ix_portfolio_game_reviews_author
ON portfolio_game_reviews (author_player_id);
CREATE INDEX ix_portfolio_game_reviews_moderator
ON portfolio_game_reviews (moderated_by_player_id)
WHERE moderated_by_player_id IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_public
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
WHERE moderation_status = 'Approved' AND publication_consent_at IS NOT NULL;
CREATE INDEX ix_portfolio_game_reviews_pending
ON portfolio_game_reviews (portfolio_game_id, created_at DESC)
WHERE moderation_status = 'Pending';
@@ -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);
+17 -3
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;
@@ -66,18 +68,29 @@ builder.Services.AddSingleton<SendJoinLinkHandler>();
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
builder.Services.AddSingleton<SendOneHourReminderHandler>();
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
builder.Services.AddSingleton<CreateSessionHandler>();
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>();
builder.Services.AddSingleton<PromoteWaitlistedPlayerHandler>();
builder.Services.AddSingleton<CancelSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.DeleteSessionHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ListSessions.ListSessionsHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.ExportCalendar.ExportCalendarHandler>();
builder.Services.AddSingleton<InitiateRescheduleHandler>();
builder.Services.AddSingleton<HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleTimeInputHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<GmRelay.Bot.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<RescheduleVotingFinalizer>();
builder.Services.AddSingleton<DirectSessionNotificationSender>();
@@ -95,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,119 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordDeleteSessionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView,
string? EmptyMessage = null);
public sealed class DiscordDeleteSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
DiscordListSessionsHandler listSessionsHandler,
ILogger<DiscordDeleteSessionHandler> logger)
{
public async Task<DiscordDeleteSessionResult> HandleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
Guid sessionId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord'
AND p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
{
return new DiscordDeleteSessionResult(
"Только owner, администратор или manager могут удалять сессии.",
UpdatedView: null);
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
await connection.ExecuteAsync(
"SELECT pg_advisory_xact_lock(20260530, 108)",
transaction: transaction);
_ = await connection.QuerySingleOrDefaultAsync<Guid?>(
"""
SELECT s.id
FROM sessions s
JOIN game_groups g ON g.id = s.group_id
WHERE s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
FOR UPDATE OF s
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await connection.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
JOIN sessions s ON s.id = pgs.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE pgs.portfolio_game_id = pg.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND pg.is_public = true
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
var deletedRows = await connection.ExecuteAsync(
"""
DELETE FROM sessions s
USING game_groups g
WHERE s.group_id = g.id
AND s.id = @SessionId
AND g.platform = 'Discord'
AND g.external_group_id = @GuildId
""",
new { SessionId = sessionId, GuildId = guildId },
transaction);
await transaction.CommitAsync(cancellationToken);
if (deletedRows == 0)
{
return new DiscordDeleteSessionResult(
"Сессия не найдена или уже удалена.",
UpdatedView: null);
}
logger.LogInformation("Deleted Discord session {SessionId} in guild {GuildId}", sessionId, guildId);
var updatedView = await listSessionsHandler.BuildScheduleAsync(
guildId,
channelId,
userId,
resolvedPermissions,
guildOwnerId,
cancellationToken);
return updatedView is null
? new DiscordDeleteSessionResult(
"Сессия удалена.",
UpdatedView: null,
EmptyMessage: "В этом сервере нет предстоящих игр.")
: new DiscordDeleteSessionResult("Сессия удалена.", updatedView);
}
}
@@ -1,3 +1,4 @@
using NetCord;
using NetCord.Rest;
using NetCord.Services.ApplicationCommands;
@@ -18,8 +19,17 @@ public class DiscordListSessionsCommand : ApplicationCommandModule<SlashCommandC
var guildId = Context.Interaction.GuildId?.ToString()
?? throw new InvalidOperationException("This command can only be used in a guild.");
var channelId = Context.Channel.Id.ToString();
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
var guildOwnerId = 0UL;
var view = await _handler.BuildScheduleAsync(guildId, channelId, CancellationToken.None);
var view = await _handler.BuildScheduleAsync(
guildId,
channelId,
Context.User.Id,
resolvedPermissions,
guildOwnerId,
CancellationToken.None);
if (view is null)
{
@@ -1,4 +1,5 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -9,11 +10,22 @@ internal sealed record DiscordSessionListItemDto(
Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers,
int PlayerCount, int WaitlistCount);
public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
public sealed class DiscordListSessionsHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker)
{
public Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
CancellationToken cancellationToken) =>
BuildScheduleAsync(guildId, channelId, 0, 0, 0, cancellationToken);
public async Task<SessionBatchViewModel?> BuildScheduleAsync(
string guildId,
string channelId,
ulong userId,
ulong resolvedPermissions,
ulong guildOwnerId,
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
@@ -29,7 +41,7 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
WHERE g.platform = 'Discord'
AND g.external_group_id = @GuildId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
AND s.scheduled_at > now() - interval '4 hours'
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players
ORDER BY s.scheduled_at ASC",
new
@@ -44,11 +56,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
if (sessionList.Count == 0)
return null;
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
new { GuildId = guildId });
var canManage = permissionChecker.CanManageSchedule(
guildOwnerId,
userId,
dbManagerUserIds,
resolvedPermissions);
var sessionIds = sessionList.Select(s => s.Id).ToList();
var participants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
@@ -60,6 +86,25 @@ public sealed class DiscordListSessionsHandler(NpgsqlDataSource dataSource)
var batchDtos = sessionList.Select(s => new SessionBatchDto(
s.Id, s.ScheduledAt, s.Status, s.MaxPlayers, "")).ToList();
return SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
var view = SessionBatchViewBuilder.Build(firstTitle, batchDtos, participants.ToList());
return canManage ? AddManagerActions(view) : view;
}
internal static SessionBatchViewModel AddManagerActions(SessionBatchViewModel view) =>
view with
{
Sessions = view.Sessions
.Select(session =>
{
if (SessionStatus.IsCancelled(session.Status))
return session;
var actions = session.AvailableActions
.Concat([new AvailableAction("delete_session", $"Удалить {session.ScheduledAt.FormatMoscowShort()}", session.SessionId)])
.ToList();
return session with { AvailableActions = actions };
})
.ToList()
};
}
@@ -44,10 +44,12 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
_logger.LogInformation("Resolved permissions for user {UserId}: {Permissions}", Context.User.Id, resolvedPermissions);
ulong guildOwnerId = 0;
var guildName = guildId.ToString();
try
{
var guild = await Context.Client.Rest.GetGuildAsync(guildId);
guildOwnerId = guild.OwnerId;
guildName = guild.Name;
_logger.LogInformation("Guild owner id: {OwnerId}", guildOwnerId);
}
catch (RestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
@@ -80,6 +82,7 @@ public class DiscordNewSessionCommand : ApplicationCommandModule<SlashCommandCon
var view = await _handler.HandleAsync(
guildId: guildId.ToString(),
channelId: Context.Channel!.Id.ToString(),
groupName: guildName,
userId: Context.User.Id,
userDisplayName: Context.User.GlobalName ?? Context.User.Username,
resolvedPermissions: resolvedPermissions,
@@ -1,9 +1,9 @@
using Dapper;
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
using System.Globalization;
namespace GmRelay.DiscordBot.Features.Sessions;
@@ -12,35 +12,40 @@ public sealed record TimeParseResult(bool IsSuccess, DateTimeOffset Value, strin
public sealed class DiscordNewSessionHandler(
NpgsqlDataSource dataSource,
DiscordPermissionChecker permissionChecker,
IPlatformMessenger messenger,
ILogger<DiscordNewSessionHandler> logger)
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
public static TimeParseResult ParseTimeInput(string input)
{
if (DateTimeOffset.TryParseExact(
input.Trim(),
var trimmed = input.Trim();
if (DateTime.TryParseExact(
trimmed,
"yyyy-MM-dd HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var result))
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt1))
{
if (result < DateTimeOffset.UtcNow)
var offset = new DateTimeOffset(dt1, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, result.ToUniversalTime(), null);
return new TimeParseResult(true, offset, null);
}
if (DateTimeOffset.TryParseExact(
input.Trim(),
if (DateTime.TryParseExact(
trimmed,
"dd.MM.yyyy HH:mm",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AssumeUniversal,
out var altResult))
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var dt2))
{
if (altResult < DateTimeOffset.UtcNow)
var offset = new DateTimeOffset(dt2, MoscowOffset).ToUniversalTime();
if (offset < DateTimeOffset.UtcNow)
return new TimeParseResult(false, default, "Дата находится в прошлом.");
return new TimeParseResult(true, altResult.ToUniversalTime(), null);
return new TimeParseResult(true, offset, null);
}
return new TimeParseResult(false, default, "Некорректный формат даты. Используйте YYYY-MM-DD HH:mm или DD.MM.YYYY HH:mm");
@@ -49,6 +54,7 @@ public sealed class DiscordNewSessionHandler(
public async Task<SessionBatchViewModel> HandleAsync(
string guildId,
string channelId,
string groupName,
ulong userId,
string userDisplayName,
ulong resolvedPermissions,
@@ -60,13 +66,18 @@ public sealed class DiscordNewSessionHandler(
CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var displayGroupName = string.IsNullOrWhiteSpace(groupName) || string.Equals(groupName, guildId, StringComparison.Ordinal)
? title
: groupName.Trim();
var dbManagerUserIds = await connection.QueryAsync<ulong>(
@"SELECT CAST(p.external_user_id AS BIGINT)
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
JOIN game_groups g ON g.id = gm.group_id
WHERE g.platform = 'Discord' AND g.external_group_id = @GuildId",
WHERE g.platform = 'Discord'
AND p.platform = 'Discord'
AND g.external_group_id = @GuildId",
new { GuildId = guildId });
if (!permissionChecker.CanManageSchedule(guildOwnerId, userId, dbManagerUserIds, resolvedPermissions))
@@ -75,6 +86,7 @@ public sealed class DiscordNewSessionHandler(
}
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
var transactionCommitted = false;
try
{
await connection.ExecuteAsync(
@@ -89,13 +101,13 @@ public sealed class DiscordNewSessionHandler(
var groupId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@GuildId, 'Discord', @GuildId, @ChannelId)
VALUES (@GroupName, 'Discord', @GuildId, @ChannelId)
ON CONFLICT (platform, external_group_id)
WHERE platform IS NOT NULL AND external_group_id IS NOT NULL
DO UPDATE SET name = EXCLUDED.name,
external_channel_id = COALESCE(EXCLUDED.external_channel_id, game_groups.external_channel_id)
RETURNING id",
new { GuildId = guildId, ChannelId = channelId },
new { GroupName = displayGroupName, GuildId = guildId, ChannelId = channelId },
transaction);
await connection.ExecuteAsync(
@@ -125,23 +137,19 @@ public sealed class DiscordNewSessionHandler(
transaction);
await transaction.CommitAsync(cancellationToken);
transactionCommitted = true;
logger.LogInformation("Created session {SessionId} in guild {GuildId}", sessionId, guildId);
var sessions = new[] { new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, maxPlayers, joinLink ?? string.Empty) };
var view = SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
await messenger.SendScheduleAsync(
new PlatformScheduleMessage(
new PlatformGroup(PlatformKind.Discord, guildId, guildId, channelId),
view,
null),
cancellationToken);
return view;
return SessionBatchViewBuilder.Build(title, sessions, Array.Empty<ParticipantBatchDto>());
}
catch
{
await transaction.RollbackAsync(cancellationToken);
if (!transactionCommitted)
{
await transaction.RollbackAsync(cancellationToken);
}
throw;
}
}
@@ -1,114 +1,46 @@
namespace GmRelay.DiscordBot.Features.Sessions;
using Dapper;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Sessions.RescheduleSession;
using GmRelay.Shared.Platform;
using Npgsql;
using NetCord.Rest;
namespace GmRelay.DiscordBot.Features.Sessions;
public sealed record DiscordRescheduleVoteInput(
Guid OptionId, ulong UserId, string InteractionId,
string GuildId, string ChannelId, string MessageId);
Guid OptionId,
ulong UserId,
string InteractionId,
string GuildId,
string ChannelId,
string MessageId);
public sealed class DiscordRescheduleVoteHandler(
NpgsqlDataSource dataSource,
GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler sharedHandler,
RestClient restClient,
ILogger<DiscordRescheduleVoteHandler> logger)
{
public async Task<string> HandleAsync(DiscordRescheduleVoteInput input, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var command = new HandleRescheduleVoteCommand(
input.OptionId,
new PlatformUser(PlatformKind.Discord, input.UserId.ToString(), string.Empty, null),
new PlatformGroup(PlatformKind.Discord, input.GuildId, string.Empty, input.ChannelId),
input.InteractionId,
new PlatformMessageRef(PlatformKind.Discord, input.ChannelId, null, input.MessageId));
// 1. Load proposal + option
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title, s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { input.OptionId },
transaction);
var result = await sharedHandler.HandleAsync(command, ct);
if (proposal is null)
return "Голосование уже завершено или не найдено.";
if (!result.Success)
{
return result.ReplyText!;
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
return "Дедлайн уже прошёл. Результаты скоро будут применены.";
// 2. Verify participant (Discord platform)
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = 'Discord'
AND p.external_user_id = @UserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, UserId = input.UserId.ToString(), Active = ParticipantRegistrationStatus.Active },
transaction);
if (playerId is null)
return "Вы не являетесь участником этой сессии.";
// 3. Upsert vote
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id, voted_at = now()
""",
new { ProposalId = proposal.Id, PlayerId = playerId.Value, input.OptionId },
transaction);
// 4. Reload participants, options, votes for re-rendering
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername, 0 AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId, display_order AS DisplayOrder, proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId, p.id AS PlayerId, p.display_name AS DisplayName, p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
// 5. Re-render and update Discord vote message
var (embed, actionRow) = DiscordRescheduleVotingRenderer.Render(
proposal.Title, proposal.CurrentScheduledAt, proposal.VotingDeadlineAt,
options, participants, votes);
result.Title!,
result.CurrentScheduledAt,
result.VotingDeadlineAt,
result.Options,
result.Participants,
result.Votes);
var channelIdUlong = ulong.Parse(input.ChannelId);
var messageIdUlong = ulong.Parse(input.MessageId);
@@ -123,9 +55,9 @@ public sealed class DiscordRescheduleVoteHandler(
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", proposal.Id);
logger.LogWarning(ex, "Failed to update Discord vote message for proposal {ProposalId}", result.ProposalId);
}
return "Ваш голос учтён. До дедлайна его можно изменить.";
return result.ReplyText!;
}
}
@@ -152,7 +152,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
@@ -1,8 +1,10 @@
using GmRelay.DiscordBot.Infrastructure.Discord;
using GmRelay.DiscordBot.Rendering;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Features.Confirmation.HandleRsvp;
using GmRelay.Shared.Features.Sessions.CreateSession;
using GmRelay.Shared.Platform;
using System.Collections;
using System.Globalization;
using NetCord;
using NetCord.Rest;
@@ -14,6 +16,7 @@ public sealed class DiscordSessionInteractionModule(
JoinSessionHandler joinSessionHandler,
LeaveSessionHandler leaveSessionHandler,
HandleRsvpHandler rsvpHandler,
DiscordDeleteSessionHandler deleteSessionHandler,
DiscordRescheduleVoteHandler voteHandler,
DiscordInteractionReplyCache interactionReplies,
ILogger<DiscordSessionInteractionModule> logger) : ComponentInteractionModule<ButtonInteractionContext>
@@ -28,21 +31,22 @@ public sealed class DiscordSessionInteractionModule(
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try
{
await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input),
result = await joinSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateJoinCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord join interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
}
[ComponentInteraction("leave_session")]
@@ -55,21 +59,56 @@ public sealed class DiscordSessionInteractionModule(
}
var input = CreateInput(parsedSessionId);
await RespondAsync(InteractionCallback.DeferredMessage(MessageFlags.Ephemeral));
await RespondAsync(InteractionCallback.DeferredModifyMessage);
SessionInteractionResult result;
try
{
await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input),
result = await leaveSessionHandler.HandleAsync(
DiscordSessionInteractionMapper.CreateLeaveCommand(input) with { DeferScheduleUpdate = true },
CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord leave interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
await FollowupEphemeralAsync("Не удалось обработать кнопку.");
return;
}
await CompleteWithStoredReplyAsync(input.InteractionId);
await CompleteScheduleUpdateResponseAsync(input.InteractionId, result);
}
[ComponentInteraction("delete_session")]
public async Task DeleteAsync(string sessionId)
{
if (!Guid.TryParse(sessionId, out var parsedSessionId))
{
await RespondAsync(CreateEphemeralReply("Session button is outdated."));
return;
}
var input = CreateInput(parsedSessionId);
var member = Context.User as GuildInteractionUser;
var resolvedPermissions = member is null ? 0UL : (ulong)member.Permissions;
await RespondAsync(InteractionCallback.DeferredModifyMessage);
try
{
var result = await deleteSessionHandler.HandleAsync(
guildId: input.GuildId,
channelId: input.ChannelId,
userId: input.UserId,
resolvedPermissions: resolvedPermissions,
guildOwnerId: 0,
sessionId: parsedSessionId,
CancellationToken.None);
await CompleteDeleteResponseAsync(result);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord delete interaction for session {SessionId}", parsedSessionId);
await FollowupEphemeralAsync("Не удалось удалить сессию.");
}
}
[ComponentInteraction("rsvp")]
@@ -124,7 +163,7 @@ public sealed class DiscordSessionInteractionModule(
catch (Exception ex)
{
logger.LogError(ex, "Failed to handle Discord RSVP interaction for session {SessionId}", parsedSessionId);
await CompleteResponseAsync("Не удалось обработать кнопку.");
await CompleteResponseAsync("Не удалось обработать кнопку.");
return;
}
@@ -190,9 +229,85 @@ public sealed class DiscordSessionInteractionModule(
await CompleteResponseAsync(reply?.Text ?? "Session updated.");
}
private async Task CompleteScheduleUpdateResponseAsync(string interactionId, SessionInteractionResult result)
{
var updatedView = result.UpdatedView;
if (updatedView is not null && SourceMessageHasDeleteAction())
{
updatedView = DiscordListSessionsHandler.AddManagerActions(updatedView);
}
if (updatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(updatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
var reply = interactionReplies.Take(interactionId);
await FollowupEphemeralAsync(reply?.Text ?? result.ReplyText);
}
private async Task CompleteDeleteResponseAsync(DiscordDeleteSessionResult result)
{
if (result.UpdatedView is not null)
{
var (embeds, actionRows) = DiscordSessionBatchRenderer.Render(result.UpdatedView);
await ModifyResponseAsync(options =>
{
options.Embeds = embeds;
options.Components = actionRows;
});
}
else if (result.EmptyMessage is not null)
{
await ModifyResponseAsync(options =>
{
options.Content = result.EmptyMessage;
options.Embeds = [];
options.Components = [];
});
}
await FollowupEphemeralAsync(result.ReplyText);
}
private Task CompleteResponseAsync(string text) =>
ModifyResponseAsync(options => options.Content = text);
private Task FollowupEphemeralAsync(string text) =>
FollowupAsync(new InteractionMessageProperties()
.WithContent(text)
.WithFlags(MessageFlags.Ephemeral));
private bool SourceMessageHasDeleteAction() =>
Context.Interaction.Message?.Components.Any(ComponentContainsDeleteAction) == true;
private static bool ComponentContainsDeleteAction(object? component)
{
if (component is null)
return false;
if (component is IInteractiveComponent interactive
&& interactive.CustomId.StartsWith("delete_session:", StringComparison.Ordinal))
return true;
var nestedComponents = component.GetType().GetProperty("Components")?.GetValue(component) as IEnumerable;
if (nestedComponents is null)
return false;
foreach (var nestedComponent in nestedComponents)
{
if (ComponentContainsDeleteAction(nestedComponent))
return true;
}
return false;
}
private static InteractionCallbackProperties CreateEphemeralReply(string text) =>
InteractionCallback.Message(
new InteractionMessageProperties()
@@ -6,11 +6,14 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
<NoWarn>$(NoWarn);DAP005</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aspire.Npgsql" Version="13.2.2" />
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper.AOT" Version="1.0.48" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
<PackageReference Include="NetCord.Hosting" Version="1.0.0-alpha.489" />
<PackageReference Include="NetCord.Hosting.Services" Version="1.0.0-alpha.489" />
@@ -77,6 +77,38 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
await restClient.SendMessageAsync(GetChannelId(group), htmlText);
}
public async Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var rows = BuildActionRows(actions);
await restClient.SendMessageAsync(GetChannelId(group), new MessageProperties().WithContent(htmlText).WithComponents(rows));
}
public async Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
var messageId = ParseSnowflake(messageRef.ExternalMessageId);
var rows = BuildActionRows(actions);
await restClient.ModifyMessageAsync(channelId, messageId, options =>
{
options.Content = htmlText;
options.Components = rows;
});
}
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
{
// Discord thread creation is not implemented in this adapter
return Task.FromResult(new PlatformMessageRef(PlatformKind.Discord, group.ExternalGroupId, group.ExternalThreadId, string.Empty));
}
public Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) => Task.CompletedTask;
public async Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct)
{
var channelId = GetChannelId(new PlatformGroup(messageRef.Platform, messageRef.ExternalGroupId, string.Empty, messageRef.ExternalThreadId));
await restClient.DeleteMessageAsync(channelId, ParseSnowflake(messageRef.ExternalMessageId));
}
public async Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct)
{
await SendDirectContentAsync(message.Recipient, message.HtmlText, ct);
@@ -98,17 +130,34 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
CancellationToken ct)
{
var channelId = GetChannelId(request.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
try
{
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties()
.WithEmbeds([BuildConfirmationEmbed(request)])
.WithComponents(BuildRsvpRows(request.SessionId, disabled: false)));
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
logger?.LogInformation(
"Confirmation request sent to Discord channel {ChannelId}, message id {MessageId}",
channelId,
message.Id);
return new PlatformMessageRef(
PlatformKind.Discord,
request.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to send confirmation request to Discord channel {ChannelId} for session {SessionId}",
channelId,
request.SessionId);
throw;
}
}
public async Task UpdateConfirmationRequestAsync(PlatformRsvpMessageUpdate update, CancellationToken ct)
@@ -135,15 +184,32 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
CancellationToken ct)
{
var channelId = GetChannelId(notification.Group);
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
try
{
var message = await restClient.SendMessageAsync(
channelId,
new MessageProperties().WithEmbeds([BuildJoinLinkEmbed(notification)]));
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
logger?.LogInformation(
"Join link sent to Discord channel {ChannelId}, message id {MessageId}",
channelId,
message.Id);
return new PlatformMessageRef(
PlatformKind.Discord,
notification.Group.ExternalGroupId,
null,
message.Id.ToString(CultureInfo.InvariantCulture));
}
catch (Exception ex)
{
logger?.LogError(
ex,
"Failed to send join link to Discord channel {ChannelId} for session {SessionId}",
channelId,
notification.SessionId);
throw;
}
}
public async Task SendDirectSessionNotificationAsync(
@@ -272,14 +338,16 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
? "—"
: string.Join(", ", notification.ConfirmedPlayers.Select(p => Mention(p.User)));
return new EmbedProperties()
var embed = new EmbedProperties()
.WithTitle($"Ссылка на игру: {notification.Title}")
.WithDescription(
$"Время: **{notification.ScheduledAt.FormatMoscow()}** (МСК)\n" +
$"Ссылка: {notification.JoinLink}\n\n" +
$"Участники: {mentions}")
.WithUrl(notification.JoinLink)
.WithColor(new Color(0x57F287));
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(notification.JoinLink);
return embedUrl is null ? embed : embed.WithUrl(embedUrl);
}
private static IReadOnlyList<ActionRowProperties> BuildRsvpRows(Guid sessionId, bool disabled)
@@ -367,6 +435,30 @@ public sealed class DiscordPlatformMessenger : IPlatformMessenger
return ParseSnowflake(channelId);
}
private static IReadOnlyList<ActionRowProperties> BuildActionRows(IReadOnlyList<PlatformMessageAction> actions)
{
if (actions.Count == 0)
{
return [];
}
var rows = new List<ActionRowProperties>();
foreach (var chunk in actions.Chunk(5))
{
var row = new ActionRowProperties();
foreach (var action in chunk)
{
row.Add(new ButtonProperties(action.Key, action.Label, ButtonStyle.Secondary)
{
CustomId = action.Payload
});
}
rows.Add(row);
}
return rows;
}
private static ulong ParseSnowflake(string value) =>
ulong.Parse(value, CultureInfo.InvariantCulture);
}
+2
View File
@@ -56,8 +56,10 @@ builder.Services.AddSingleton<NpgsqlDataSource>(sp =>
builder.Services.AddSingleton<DiscordPermissionChecker>();
builder.Services.AddSingleton<DiscordListSessionsHandler>();
builder.Services.AddSingleton<DiscordDeleteSessionHandler>();
builder.Services.AddSingleton<DiscordNewSessionHandler>();
builder.Services.AddSingleton<DiscordRescheduleHandler>();
builder.Services.AddSingleton<GmRelay.Shared.Features.Sessions.RescheduleSession.HandleRescheduleVoteHandler>();
builder.Services.AddSingleton<DiscordRescheduleVoteHandler>();
builder.Services.AddSingleton<IScheduleMessageUpdateLock, ScheduleMessageUpdateLock>();
builder.Services.AddSingleton<JoinSessionHandler>();
@@ -0,0 +1,43 @@
namespace GmRelay.DiscordBot.Rendering;
public static class DiscordEmbedUrls
{
public static string? NormalizeHttpUrl(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var candidate = value.Trim();
if (IsSupportedHttpUrl(candidate, out var normalized))
return normalized;
if (candidate.Contains("://", StringComparison.Ordinal))
return null;
return IsSupportedHttpUrl($"https://{candidate}", out normalized)
&& HasPublicHost(normalized)
? normalized
: null;
}
private static bool IsSupportedHttpUrl(string value, out string normalized)
{
normalized = string.Empty;
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return false;
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return false;
}
normalized = uri.ToString();
return true;
}
private static bool HasPublicHost(string value) =>
Uri.TryCreate(value, UriKind.Absolute, out var uri)
&& uri.Host.Contains('.', StringComparison.Ordinal);
}
@@ -70,9 +70,10 @@ public static class DiscordSessionBatchRenderer
.WithInline()
};
if (!string.IsNullOrEmpty(session.JoinLink))
var embedUrl = DiscordEmbedUrls.NormalizeHttpUrl(session.JoinLink);
if (embedUrl is not null)
{
embed = embed.WithUrl(session.JoinLink);
embed = embed.WithUrl(embedUrl);
}
embed = embed.WithColor(GetColor(session));
@@ -28,6 +28,12 @@
"resolved": "2.1.72",
"contentHash": "ns4mGqQd9a/MhP8m6w556vVlZIa0/MfUu03zrxjZC/jlr1uVCsUac8bkdB+Fs98Llbd56rRSo1eZH5VVmeGZyw=="
},
"Dapper.AOT": {
"type": "Direct",
"requested": "[1.0.48, )",
"resolved": "1.0.48",
"contentHash": "rsLM3yKr4g+YKKox9lhc8D+kz67P7Q9+xdyn1LmCsoYr1kYpJSm+Nt6slo5UrfUrcTiGJ57zUlyO8XUdV7G7iA=="
},
"Microsoft.Extensions.Hosting": {
"type": "Direct",
"requested": "[10.0.5, )",
+83
View File
@@ -0,0 +1,83 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain;
public enum GameSystem
{
Dnd5e,
Pathfinder2e,
CallOfCthulhu7e,
Shadowdark,
OldSchoolEssentials,
Dragonbane,
BladesInTheDark,
Daggerheart,
CyberpunkRed,
Mothership,
AlienRpg,
WarhammerFantasy,
VampireMasquerade5e,
StarWarsFfg,
Genesys,
SavageWorlds,
GURPS,
Fate,
DungeonWorld,
Ironsworn,
Other
}
public static class GameSystemExtensions
{
private static readonly FrozenDictionary<GameSystem, string> DisplayNames =
new Dictionary<GameSystem, string>
{
[GameSystem.Dnd5e] = "D&D 5e",
[GameSystem.Pathfinder2e] = "Pathfinder 2e",
[GameSystem.CallOfCthulhu7e] = "Call of Cthulhu 7e",
[GameSystem.Shadowdark] = "Shadowdark",
[GameSystem.OldSchoolEssentials] = "Old School Essentials",
[GameSystem.Dragonbane] = "Dragonbane",
[GameSystem.BladesInTheDark] = "Blades in the Dark",
[GameSystem.Daggerheart] = "Daggerheart",
[GameSystem.CyberpunkRed] = "Cyberpunk RED",
[GameSystem.Mothership] = "Mothership",
[GameSystem.AlienRpg] = "Alien RPG",
[GameSystem.WarhammerFantasy] = "Warhammer Fantasy",
[GameSystem.VampireMasquerade5e] = "Vampire: The Masquerade 5e",
[GameSystem.StarWarsFfg] = "Star Wars (FFG)",
[GameSystem.Genesys] = "Genesys",
[GameSystem.SavageWorlds] = "Savage Worlds",
[GameSystem.GURPS] = "GURPS",
[GameSystem.Fate] = "Fate",
[GameSystem.DungeonWorld] = "Dungeon World",
[GameSystem.Ironsworn] = "Ironsworn",
[GameSystem.Other] = "Другое"
}.ToFrozenDictionary();
public static string ToDisplayName(this GameSystem system) =>
DisplayNames.TryGetValue(system, out var name) ? name : "Другое";
public static GameSystem? TryParseFuzzy(string input)
{
if (string.IsNullOrWhiteSpace(input))
return null;
var normalized = input.Trim().ToLowerInvariant();
if (Enum.TryParse<GameSystem>(normalized, true, out var exact))
return exact;
foreach (var value in Enum.GetValues<GameSystem>())
{
if (value == GameSystem.Other)
continue;
var display = value.ToDisplayName().ToLowerInvariant();
if (display == normalized || display.Contains(normalized) || normalized.Contains(display))
return value;
}
return GameSystem.Other;
}
}
@@ -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.")
};
}
@@ -56,8 +56,8 @@ public sealed class HandleRsvpHandler(
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND COALESCE(p.platform, 'Telegram') = @Platform
AND COALESCE(p.external_user_id, p.telegram_id::TEXT) = @ExternalUserId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
)
@@ -90,8 +90,8 @@ public sealed class HandleRsvpHandler(
AND player_id = (
SELECT id
FROM players
WHERE COALESCE(platform, 'Telegram') = @Platform
AND COALESCE(external_user_id, telegram_id::TEXT) = @ExternalUserId
WHERE platform = @Platform
AND external_user_id = @ExternalUserId
LIMIT 1
)
AND registration_status = @Active
@@ -265,10 +265,10 @@ public sealed class HandleRsvpHandler(
var participants = (await connection.QueryAsync<ParticipantRsvpRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
@@ -312,23 +312,13 @@ public sealed class HandleRsvpHandler(
var rows = await connection.QueryAsync<RsvpRecipientRow>(
"""
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
p.external_username AS ExternalUsername
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = @GroupId
UNION
SELECT DISTINCT
COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
FROM game_groups g
JOIN players p ON p.telegram_id = g.gm_telegram_id
WHERE g.id = @GroupId
AND g.gm_telegram_id IS NOT NULL
""",
new { GroupId = groupId },
transaction);
@@ -45,10 +45,10 @@ public sealed class SendConfirmationHandler(
s.title,
s.scheduled_at AS ScheduledAt,
s.group_id AS GroupId,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.platform AS Platform,
g.external_group_id AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
g.external_channel_id AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
@@ -65,10 +65,10 @@ public sealed class SendConfirmationHandler(
var participants = (await connection.QueryAsync<ConfirmationParticipantRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
@@ -47,10 +47,10 @@ public sealed class SendJoinLinkHandler(
s.title,
s.join_link AS JoinLink,
s.scheduled_at AS ScheduledAt,
COALESCE(g.platform, 'Telegram') AS Platform,
COALESCE(g.external_group_id, g.telegram_chat_id::TEXT) AS ExternalGroupId,
g.platform AS Platform,
g.external_group_id AS ExternalGroupId,
g.name AS DisplayName,
COALESCE(g.external_channel_id, g.telegram_chat_id::TEXT) AS ExternalChannelId,
g.external_channel_id AS ExternalChannelId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM sessions s
@@ -58,14 +58,14 @@ public sealed class SendJoinLinkHandler(
WHERE s.id = @SessionId
AND s.status = @Confirmed
AND (
(COALESCE(g.platform, 'Telegram') = 'Telegram' AND s.link_message_id IS NULL)
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
OR (
COALESCE(g.platform, 'Telegram') <> 'Telegram'
g.platform <> 'Telegram'
AND NOT EXISTS (
SELECT 1
FROM platform_messages pm
WHERE pm.session_id = s.id
AND pm.platform = COALESCE(g.platform, 'Telegram')
AND pm.platform = g.platform
AND pm.purpose = 'join_link'
)
)
@@ -81,10 +81,10 @@ public sealed class SendJoinLinkHandler(
var players = (await connection.QueryAsync<JoinLinkPlayerRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
p.external_username AS ExternalUsername,
sp.rsvp_status AS RsvpStatus,
sp.registration_status AS RegistrationStatus,
sp.is_gm AS IsGm
@@ -56,10 +56,10 @@ public sealed class SendOneHourReminderHandler(
var recipients = (await connection.QueryAsync<OneHourReminderRecipientRow>(
"""
SELECT COALESCE(p.platform, 'Telegram') AS Platform,
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
SELECT p.platform AS Platform,
p.external_user_id AS ExternalUserId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername
p.external_username AS ExternalUsername
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -0,0 +1,18 @@
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionCommand(
PlatformUser User,
PlatformGroup Group,
string Title,
string Link,
IReadOnlyList<DateTimeOffset> ScheduledTimes,
int? MaxPlayers,
string? ImageReference,
GameSystem? System = null,
string? Description = null,
string? Format = null,
int? DurationMinutes = null,
bool IsOneShot = false);
@@ -0,0 +1,168 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
public sealed class CreateSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var transactionCommitted = false;
try
{
var platform = command.User.Platform.ToString();
var externalUserId = command.User.ExternalUserId;
var displayName = command.User.DisplayName;
var externalUsername = command.User.ExternalUsername;
await connection.ExecuteAsync(
"""
INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @Platform, @ExternalId, @Username)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
external_username = EXCLUDED.external_username;
""",
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername, Platform = platform },
transaction);
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
"""
SELECT g.id AS GroupId,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = g.id
AND p.platform = @Platform
AND p.external_user_id = @ExternalGmId
) AS CanManage
FROM game_groups g
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
""",
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
transaction);
Guid groupId;
if (existingGroup is null)
{
groupId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
RETURNING id;
""",
new
{
Platform = platform,
ExternalGroupId = command.Group.ExternalGroupId,
ExternalChannelId = command.Group.ExternalChannelId,
ChatName = command.Group.DisplayName
},
transaction);
await connection.ExecuteAsync(
"""
INSERT INTO group_managers (group_id, player_id, role)
SELECT @GroupId, p.id, @OwnerRole
FROM players p
WHERE p.platform = @Platform
AND p.external_user_id = @ExternalGmId
ON CONFLICT (group_id, player_id) DO NOTHING
""",
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
transaction);
}
else
{
if (!existingGroup.CanManage)
{
await transaction.RollbackAsync(ct);
return new CreateSessionResult(
false,
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
null,
null,
null,
Array.Empty<string>());
}
groupId = existingGroup.GroupId;
await connection.ExecuteAsync(
"""
UPDATE game_groups
SET name = @ChatName
WHERE id = @GroupId
""",
new { ChatName = command.Group.DisplayName, GroupId = groupId },
transaction);
}
var batchId = Guid.NewGuid();
var sessions = new List<SessionBatchDto>();
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
foreach (var scheduledAt in orderedTimes)
{
var sessionId = await connection.ExecuteScalarAsync<Guid>(
"""
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
RETURNING id;
""",
new
{
BatchId = batchId,
GroupId = groupId,
command.Title,
Link = command.Link,
ScheduledAt = scheduledAt,
Status = SessionStatus.Planned,
MaxPlayers = command.MaxPlayers,
System = command.System?.ToString(),
command.Description,
command.Format,
DurationMinutes = command.DurationMinutes,
IsOneShot = command.IsOneShot,
CoverImageUrl = command.ImageReference
},
transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
}
await transaction.CommitAsync(ct);
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
return new CreateSessionResult(
true,
null,
view,
batchId,
groupId,
Array.Empty<string>());
}
catch
{
if (!transactionCommitted)
{
await transaction.RollbackAsync(ct);
}
throw;
}
}
}
@@ -0,0 +1,11 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.CreateSession;
public sealed record CreateSessionResult(
bool Success,
string? ErrorMessage,
SessionBatchViewModel? View,
Guid? BatchId,
Guid? GroupId,
IReadOnlyList<string> Warnings);
@@ -13,7 +13,12 @@ public sealed record JoinSessionCommand(
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
public sealed record SessionInteractionResult(
string ReplyText,
SessionBatchViewModel? UpdatedView = null);
// DTOs for AOT compilation
internal sealed record JoinSessionBatchDto(Guid BatchId, string Title, string Status, int? MaxPlayers);
@@ -24,7 +29,7 @@ public sealed class JoinSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<JoinSessionHandler> logger)
{
public async Task HandleAsync(JoinSessionCommand command, CancellationToken ct)
public async Task<SessionInteractionResult> HandleAsync(JoinSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
@@ -35,30 +40,19 @@ public sealed class JoinSessionHandler(
{
// 1. Убеждаемся, что игрок есть в базе
var platform = command.User.Platform.ToString();
var legacyTelegramId = command.User.Platform == PlatformKind.Telegram
? long.Parse(command.User.ExternalUserId, CultureInfo.InvariantCulture)
: (long?)null;
var legacyTelegramUsername = command.User.Platform == PlatformKind.Telegram
? command.User.ExternalUsername
: null;
var playerId = await connection.ExecuteScalarAsync<Guid>(
@"INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
VALUES (@LegacyTelegramId, @Name, @LegacyTelegramUsername, @Platform, @ExternalUserId, @ExternalUsername)
@"INSERT INTO players (display_name, platform, external_user_id, external_username)
VALUES (@Name, @Platform, @ExternalUserId, @ExternalUsername)
ON CONFLICT (platform, external_user_id)
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
DO UPDATE
SET display_name = EXCLUDED.display_name,
telegram_username = COALESCE(EXCLUDED.telegram_username, players.telegram_username),
platform = EXCLUDED.platform,
external_user_id = EXCLUDED.external_user_id,
external_username = EXCLUDED.external_username
RETURNING id;",
new
{
LegacyTelegramId = legacyTelegramId,
Name = command.User.DisplayName,
LegacyTelegramUsername = legacyTelegramUsername,
Platform = platform,
command.User.ExternalUserId,
command.User.ExternalUsername
@@ -77,15 +71,13 @@ public sealed class JoinSessionHandler(
if (batchInfo is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
}
if (SessionStatus.IsCancelled(batchInfo.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
}
var existingRegistrationStatus = await connection.ExecuteScalarAsync<string?>(
@@ -105,8 +97,7 @@ public sealed class JoinSessionHandler(
var alreadyText = existingRegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы уже в листе ожидания!"
: "Вы уже записаны!";
await AnswerAsync(command.InteractionId, alreadyText, ct);
return;
return await AnswerAsync(command.InteractionId, alreadyText, ct);
}
var activeParticipants = await connection.ExecuteScalarAsync<int>(
@@ -139,8 +130,7 @@ public sealed class JoinSessionHandler(
if (inserted == 0)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
return;
return await AnswerAsync(command.InteractionId, "Вы уже записаны!", ct);
}
// Загружаем весь батч для перерисовки
@@ -154,7 +144,7 @@ public sealed class JoinSessionHandler(
var batchParticipants = await connection.QueryAsync<ParticipantBatchDto>(
@"SELECT sp.session_id as SessionId,
p.display_name as DisplayName,
COALESCE(p.external_username, p.telegram_username) as TelegramUsername,
p.external_username as TelegramUsername,
sp.registration_status as RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -168,17 +158,20 @@ public sealed class JoinSessionHandler(
// 4. Перерисовываем сообщение
var view = SessionBatchViewBuilder.Build(batchInfo.Title, batchSessions.ToList(), batchParticipants.ToList());
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
if (!command.DeferScheduleUpdate)
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
}
var callbackText = registrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Основной состав заполнен. Вы добавлены в лист ожидания."
: "Вы успешно записаны!";
await AnswerAsync(command.InteractionId, callbackText, ct);
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
}
catch (Exception ex)
{
@@ -191,10 +184,17 @@ public sealed class JoinSessionHandler(
var errorText = transactionCommitted
? "Регистрация сохранена, но не удалось обновить сообщение расписания."
: "Произошла ошибка при регистрации.";
await AnswerAsync(command.InteractionId, errorText, ct);
return await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
private async Task<SessionInteractionResult> AnswerAsync(
string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
}
@@ -12,7 +12,8 @@ public sealed record LeaveSessionCommand(
PlatformUser User,
string InteractionId,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
PlatformMessageRef ScheduleMessage,
bool DeferScheduleUpdate = false);
internal sealed record LeaveSessionInfoDto(string Title, Guid BatchId, string Status, int? MaxPlayers);
internal sealed record LeaveSessionParticipantDto(Guid ParticipantRowId, string DisplayName, string RegistrationStatus);
@@ -24,7 +25,7 @@ public sealed class LeaveSessionHandler(
IScheduleMessageUpdateLock scheduleUpdateLock,
ILogger<LeaveSessionHandler> logger)
{
public async Task HandleAsync(LeaveSessionCommand command, CancellationToken ct)
public async Task<SessionInteractionResult> HandleAsync(LeaveSessionCommand command, CancellationToken ct)
{
await using var updateLock = await scheduleUpdateLock.AcquireAsync(command.ScheduleMessage, ct);
await using var connection = await dataSource.OpenConnectionAsync(ct);
@@ -49,15 +50,13 @@ public sealed class LeaveSessionHandler(
if (session is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия не найдена.", ct);
}
if (SessionStatus.IsCancelled(session.Status))
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
return;
return await AnswerAsync(command.InteractionId, "Сессия уже отменена.", ct);
}
var platform = command.User.Platform.ToString();
@@ -81,8 +80,7 @@ public sealed class LeaveSessionHandler(
if (participant is null)
{
await transaction.RollbackAsync(ct);
await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
return;
return await AnswerAsync(command.InteractionId, "Вы не записаны на эту сессию.", ct);
}
await connection.ExecuteAsync(
@@ -175,7 +173,7 @@ public sealed class LeaveSessionHandler(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
COALESCE(p.external_username, p.telegram_username) AS TelegramUsername,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
@@ -190,12 +188,15 @@ public sealed class LeaveSessionHandler(
transactionCommitted = true;
var view = SessionBatchViewBuilder.Build(session.Title, batchSessions, batchParticipants);
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
if (!command.DeferScheduleUpdate)
{
await messenger.UpdateScheduleAsync(
new PlatformScheduleMessage(
command.Group,
view,
command.ScheduleMessage),
ct);
}
var callbackText = participant.RegistrationStatus == ParticipantRegistrationStatus.Waitlisted
? "Вы удалены из листа ожидания."
@@ -203,7 +204,7 @@ public sealed class LeaveSessionHandler(
? "Вы отписались от сессии."
: $"Вы отписались от сессии. Место получил(а) {promotedDisplayName}.";
await AnswerAsync(command.InteractionId, callbackText, ct);
return await AnswerAsync(command.InteractionId, callbackText, ct, view);
}
catch (Exception ex)
{
@@ -216,10 +217,17 @@ public sealed class LeaveSessionHandler(
var errorText = transactionCommitted
? "Запись снята, но не удалось обновить сообщение расписания."
: "Произошла ошибка при отмене записи.";
await AnswerAsync(command.InteractionId, errorText, ct);
return await AnswerAsync(command.InteractionId, errorText, ct);
}
}
private Task AnswerAsync(string interactionId, string text, CancellationToken ct) =>
messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
private async Task<SessionInteractionResult> AnswerAsync(
string interactionId,
string text,
CancellationToken ct,
SessionBatchViewModel? updatedView = null)
{
await messenger.AnswerInteractionAsync(new PlatformInteractionReply(interactionId, text), ct);
return new SessionInteractionResult(text, updatedView);
}
}
@@ -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
{
}
@@ -0,0 +1,7 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
public sealed record ExportCalendarCommand(
PlatformGroup Group,
PlatformUser User);
@@ -0,0 +1,111 @@
using System.Text;
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Configuration;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
public sealed class ExportCalendarHandler(
NpgsqlDataSource dataSource,
IPlatformMessenger messenger,
IConfiguration configuration)
{
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<CalendarSessionDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
+ " FROM sessions s"
+ " JOIN game_groups g ON s.group_id = g.id"
+ " WHERE g.platform = @Platform"
+ " AND g.external_group_id = @ExternalGroupId"
+ " AND s.status = @Planned"
+ " AND s.scheduled_at > NOW()"
+ " ORDER BY s.scheduled_at ASC",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList();
if (sessionsList.Count == 0)
{
await messenger.SendGroupMessageAsync(
command.Group,
"📭 У этой группы нет запланированных сессий для экспорта.",
cancellationToken);
return;
}
var sb = new StringBuilder();
sb.AppendLine("BEGIN:VCALENDAR");
sb.AppendLine("VERSION:2.0");
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
foreach (var s in sessionsList)
{
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
sb.AppendLine("BEGIN:VEVENT");
sb.AppendLine($"UID:{s.Id}@gmrelay");
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
sb.AppendLine($"DTSTART:{dtStart}");
sb.AppendLine($"DTEND:{dtEnd}");
sb.AppendLine($"SUMMARY:{s.Title}");
sb.AppendLine("END:VEVENT");
}
sb.AppendLine("END:VCALENDAR");
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
// Create calendar subscription
string? subscriptionUrl = null;
var baseUrl = configuration["Web:BaseUrl"];
var senderId = command.User.ExternalUserId;
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
{
try
{
var token = Guid.NewGuid().ToString("N");
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
await connection.ExecuteAsync(
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
}
catch
{
// Non-critical: if subscription creation fails, still send the file
}
}
var actions = subscriptionUrl is not null
? new[]
{
new PlatformMessageAction(
"calendar-subscription",
"🔗 Подписаться на календарь",
subscriptionUrl)
}
: Array.Empty<PlatformMessageAction>();
await messenger.SendCalendarFileAsync(
new PlatformCalendarFile(
command.Group,
"schedule.ics",
bytes,
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
actions),
cancellationToken);
}
}
@@ -0,0 +1,111 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Microsoft.Extensions.Logging;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
internal sealed record DeleteSessionInfoDto(
string Title,
Guid BatchId,
Guid GroupId,
bool CanManage,
int? ThreadId,
bool TopicCreatedByBot);
public sealed record DeleteSessionResult(
bool Success,
string? ReplyText,
string? Title,
Guid? GroupId,
int? ThreadId,
bool TopicCreatedByBot,
int RemainingInTopic);
public sealed class DeleteSessionHandler(
NpgsqlDataSource dataSource)
{
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
// 1. Use the database mutation order before locking the session or linked portfolio cards.
await connection.ExecuteAsync(
"SELECT pg_advisory_xact_lock(20260530, 108)",
transaction: transaction);
// 2. Lock the session before any linked portfolio card and verify group manager.
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
"""
SELECT s.title AS Title,
s.batch_id AS BatchId,
s.group_id AS GroupId,
s.thread_id AS ThreadId,
s.topic_created_by_bot AS TopicCreatedByBot,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players p ON p.id = gm.player_id
WHERE gm.group_id = s.group_id
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
WHERE s.id = @SessionId
FOR UPDATE OF s
""",
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
if (session == null)
{
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
}
if (!session.CanManage)
{
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
}
// 3. Unpublish a linked portfolio card before its required session link cascades away.
await connection.ExecuteAsync(
"""
UPDATE portfolio_games pg
SET is_public = false,
updated_at = now()
FROM portfolio_game_sessions pgs
WHERE pgs.portfolio_game_id = pg.id
AND pgs.session_id = @SessionId
AND pg.is_public = true
""",
new { command.SessionId },
transaction);
// 4. Delete session
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
var remainingInTopic = session.ThreadId.HasValue
? await connection.ExecuteScalarAsync<int>(
"""
SELECT COUNT(*)
FROM sessions
WHERE group_id = @GroupId
AND thread_id = @ThreadId
""",
new { session.GroupId, ThreadId = session.ThreadId.Value },
transaction)
: 0;
await transaction.CommitAsync(ct);
return new DeleteSessionResult(
true,
"Сессия удалена!",
session.Title,
session.GroupId,
session.ThreadId,
session.TopicCreatedByBot,
remainingInTopic);
}
}
@@ -0,0 +1,13 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record ListSessionsCommand(
PlatformGroup Group,
PlatformUser User);
public sealed record DeleteSessionCommand(
Guid SessionId,
PlatformUser User,
PlatformGroup Group,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,57 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.ListSessions;
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
public sealed record SessionListResult(
IReadOnlyList<SessionListItemDto> Sessions,
bool CanManage);
public sealed class ListSessionsHandler(
NpgsqlDataSource dataSource)
{
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
{
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
var sessions = await connection.QueryAsync<SessionListItemDto>(
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.platform = @Platform
AND manager_player.external_user_id = @ExternalUserId
) AS CanManage
FROM sessions s
JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
AND s.status != @Cancelled
AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
ORDER BY s.scheduled_at ASC",
new
{
Platform = command.Group.Platform.ToString(),
ExternalGroupId = command.Group.ExternalGroupId,
ExternalUserId = command.User.ExternalUserId,
Cancelled = SessionStatus.Cancelled,
Active = ParticipantRegistrationStatus.Active,
Waitlisted = ParticipantRegistrationStatus.Waitlisted
});
var sessionsList = sessions.ToList();
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
return new SessionListResult(sessionsList, canManage);
}
}
@@ -0,0 +1,12 @@
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
internal sealed record AwaitingProposalDto(
Guid Id,
Guid SessionId,
string Title,
DateTime CurrentScheduledAt,
Guid BatchId,
int? BatchMessageId,
string ExternalGroupId,
int? ThreadId,
string NotificationMode);
@@ -0,0 +1,15 @@
using GmRelay.Shared.Platform;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputCommand(
PlatformUser User,
PlatformGroup Group,
string Text);
public sealed record HandleRescheduleVoteCommand(
Guid OptionId,
PlatformUser User,
PlatformGroup Group,
string InteractionId,
PlatformMessageRef ScheduleMessage);
@@ -0,0 +1,181 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using GmRelay.Shared.Rendering;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleTimeInputHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
HandleRescheduleTimeInputCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var platform = command.User.Platform.ToString();
var externalGmId = command.User.ExternalUserId;
var externalGroupId = command.Group.ExternalGroupId;
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
"""
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
g.external_group_id AS ExternalGroupId,
s.thread_id AS ThreadId,
s.notification_mode AS NotificationMode
FROM reschedule_proposals rp
JOIN sessions s ON s.id = rp.session_id
JOIN game_groups g ON g.id = s.group_id
WHERE rp.proposed_by_external_user_id = @ExternalGmId
AND rp.status = 'AwaitingTime'
AND g.platform = @Platform
AND g.external_group_id = @ExternalGroupId
AND EXISTS (
SELECT 1
FROM group_managers gm
JOIN players manager_player ON manager_player.id = gm.player_id
WHERE gm.group_id = s.group_id
AND manager_player.platform = @Platform
AND manager_player.external_user_id = @ExternalGmId
)
ORDER BY rp.created_at DESC
LIMIT 1
""",
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
if (proposal is null)
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
{
return new HandleRescheduleTimeInputResult(
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
}
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
if (participants.Count == 0)
{
var newTime = votingInput.Options[0];
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
var replyText =
$"""✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>""";
return new HandleRescheduleTimeInputResult(
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
}
await using var transaction = await connection.BeginTransactionAsync(ct);
var options = votingInput.Options
.Select((proposedAt, index) => new RescheduleOptionDto(
Guid.NewGuid(),
index + 1,
proposedAt))
.ToList();
await connection.ExecuteAsync(
"""
UPDATE reschedule_proposals
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
WHERE id = @Id
""",
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
transaction);
foreach (var option in options)
{
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
""",
new
{
option.OptionId,
ProposalId = proposal.Id,
option.ProposedAt,
option.DisplayOrder
},
transaction);
}
await transaction.CommitAsync(ct);
return new HandleRescheduleTimeInputResult(
true,
false,
null,
null,
proposal.Id,
votingInput.Deadline,
options,
participants,
[],
proposal.Title,
proposal.CurrentScheduledAt,
null);
}
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
NpgsqlConnection connection,
AwaitingProposalDto proposal,
DateTimeOffset newTime,
CancellationToken ct)
{
await using var transaction = await connection.BeginTransactionAsync(ct);
await connection.ExecuteAsync(
"""
UPDATE sessions
SET scheduled_at = @NewTime,
status = @Status,
confirmation_message_id = NULL,
confirmation_sent_at = NULL,
one_hour_reminder_processed_at = NULL,
updated_at = now()
WHERE id = @SessionId
""",
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction);
await connection.ExecuteAsync(
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
new { NewTime = newTime, Id = proposal.Id },
transaction);
await transaction.CommitAsync(ct);
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
new { proposal.BatchId })).ToList();
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
"""
SELECT sp.session_id AS SessionId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
sp.registration_status AS RegistrationStatus
FROM session_participants sp
JOIN players p ON sp.player_id = p.id
JOIN sessions s ON sp.session_id = s.id
WHERE s.batch_id = @BatchId AND sp.is_gm = false
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
""",
new { proposal.BatchId })).ToList();
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
}
}
@@ -0,0 +1,17 @@
using GmRelay.Shared.Rendering;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleTimeInputResult(
bool Handled,
bool IsRescheduledImmediately,
string? ReplyText,
SessionBatchViewModel? UpdatedView,
Guid? ProposalId,
DateTimeOffset? VotingDeadlineAt,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionVoteDto> Votes,
string? Title,
DateTime CurrentScheduledAt,
int? BatchMessageId);
@@ -0,0 +1,156 @@
using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Platform;
using Npgsql;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed class HandleRescheduleVoteHandler(
NpgsqlDataSource dataSource)
{
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
await using var transaction = await connection.BeginTransactionAsync(ct);
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
"""
SELECT rp.id AS Id,
rp.session_id AS SessionId,
rp.voting_deadline_at AS VotingDeadlineAt,
s.title AS Title,
s.scheduled_at AS CurrentScheduledAt
FROM reschedule_options ro
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
JOIN sessions s ON s.id = rp.session_id
WHERE ro.id = @OptionId AND rp.status = 'Voting'
""",
new { command.OptionId },
transaction);
if (proposal is null)
{
return new HandleRescheduleVoteResult(
false,
"Голосование уже завершено или не найдено.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
{
return new HandleRescheduleVoteResult(
false,
"Дедлайн уже прошёл. Результаты скоро будут применены.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
var playerId = await connection.ExecuteScalarAsync<Guid?>(
"""
SELECT p.id
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND p.platform = @Platform
AND p.external_user_id = @ExternalUserId
AND sp.is_gm = false
AND sp.registration_status = @Active
""",
new
{
proposal.SessionId,
Platform = command.User.Platform.ToString(),
ExternalUserId = command.User.ExternalUserId,
Active = ParticipantRegistrationStatus.Active
},
transaction);
if (playerId is null)
{
return new HandleRescheduleVoteResult(
false,
"Вы не являетесь участником этой сессии.",
null, null, null, default, default,
Array.Empty<VoteParticipantDto>(),
Array.Empty<RescheduleOptionDto>(),
Array.Empty<RescheduleOptionVoteDto>());
}
await connection.ExecuteAsync(
"""
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
VALUES (@ProposalId, @PlayerId, @OptionId)
ON CONFLICT (proposal_id, player_id) DO UPDATE
SET option_id = EXCLUDED.option_id,
voted_at = now()
""",
new
{
ProposalId = proposal.Id,
PlayerId = playerId.Value,
command.OptionId
},
transaction);
var participants = (await connection.QueryAsync<VoteParticipantDto>(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername,
p.external_user_id AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
ORDER BY p.display_name
""",
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
transaction)).ToList();
var options = (await connection.QueryAsync<RescheduleOptionDto>(
"""
SELECT id AS OptionId,
display_order AS DisplayOrder,
proposed_at AS ProposedAt
FROM reschedule_options
WHERE proposal_id = @ProposalId
ORDER BY display_order
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
"""
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
ORDER BY rov.voted_at, p.display_name
""",
new { ProposalId = proposal.Id },
transaction)).ToList();
await transaction.CommitAsync(ct);
return new HandleRescheduleVoteResult(
true,
"Ваш голос учтён. До дедлайна его можно изменить.",
proposal.Id,
proposal.SessionId,
proposal.Title,
proposal.CurrentScheduledAt,
proposal.VotingDeadlineAt,
participants,
options,
votes);
}
}
@@ -0,0 +1,15 @@
using GmRelay.Shared.Features.Sessions.RescheduleSession;
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
public sealed record HandleRescheduleVoteResult(
bool Success,
string? ReplyText,
Guid? ProposalId,
Guid? SessionId,
string? Title,
DateTime CurrentScheduledAt,
DateTimeOffset VotingDeadlineAt,
IReadOnlyList<VoteParticipantDto> Participants,
IReadOnlyList<RescheduleOptionDto> Options,
IReadOnlyList<RescheduleOptionVoteDto> Votes);
@@ -78,8 +78,8 @@ public sealed class RescheduleVotingFinalizer(
"""
SELECT p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername,
p.telegram_id AS TelegramId
p.external_username AS TelegramUsername,
p.external_user_id::BIGINT AS TelegramId
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
@@ -107,7 +107,7 @@ public sealed class RescheduleVotingFinalizer(
SELECT rov.option_id AS OptionId,
p.id AS PlayerId,
p.display_name AS DisplayName,
p.telegram_username AS TelegramUsername
p.external_username AS TelegramUsername
FROM reschedule_option_votes rov
JOIN players p ON p.id = rov.player_id
WHERE rov.proposal_id = @ProposalId
@@ -0,0 +1,23 @@
namespace GmRelay.Shared.Features.Showcase;
public sealed record ShowcaseFilter(
DateFilter Date = DateFilter.All,
SeatFilter Seats = SeatFilter.Any,
string? System = null,
bool? IsOneShot = null,
string? Format = null);
public enum DateFilter
{
Today,
Tomorrow,
ThisWeek,
All
}
public enum SeatFilter
{
Available,
Waitlist,
Any
}
@@ -0,0 +1,24 @@
namespace GmRelay.Shared.Features.Showcase;
public sealed record ShowcaseSessionDto(
Guid Id,
Guid GroupId,
string GroupName,
string? GroupSlug,
string Title,
DateTime ScheduledAt,
string Status,
string? System,
bool IsOneShot,
string? Format,
int? DurationMinutes,
string? CoverImageUrl,
int? MaxPlayers,
int ActivePlayerCount,
int WaitlistedPlayerCount,
bool AllowDirectRegistration,
string? Description,
string PublicationMode = "None",
bool IsMembersOnly = false,
string? MasterProfileSlug = null,
string? MasterDisplayName = null);
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using GmRelay.Shared.Features.Confirmation.SendConfirmation;
using GmRelay.Shared.Features.Reminders.SendJoinLink;
using GmRelay.Shared.Features.Reminders.SendOneHourReminder;
@@ -20,6 +21,11 @@ public sealed class SessionSchedulerService(
ILogger<SessionSchedulerService> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
private static readonly TimeSpan BackoffDuration = TimeSpan.FromMinutes(15);
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _confirmationBackoff = new();
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _oneHourBackoff = new();
private readonly ConcurrentDictionary<Guid, DateTimeOffset> _joinLinkBackoff = new();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
@@ -71,14 +77,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds)
{
if (_confirmationBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping confirmation for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try
{
await confirmationHandler.HandleAsync(sessionId, ct);
_confirmationBackoff.TryRemove(sessionId, out _);
logger.LogInformation("Confirmation sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send confirmation for session {SessionId}", sessionId);
var nextAttempt = now.Add(BackoffDuration);
_confirmationBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to send confirmation for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
}
}
}
@@ -98,14 +120,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds)
{
if (_oneHourBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping one-hour reminder for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try
{
await oneHourReminderHandler.HandleAsync(sessionId, ct);
_oneHourBackoff.TryRemove(sessionId, out _);
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
var nextAttempt = now.Add(BackoffDuration);
_oneHourBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to process one-hour reminder for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
}
}
}
@@ -125,14 +163,30 @@ public sealed class SessionSchedulerService(
foreach (var sessionId in sessionIds)
{
if (_joinLinkBackoff.TryGetValue(sessionId, out var backoffUntil) && backoffUntil > now)
{
logger.LogDebug(
"Skipping join link for session {SessionId} until {Backoff}",
sessionId,
backoffUntil);
continue;
}
try
{
await joinLinkHandler.HandleAsync(sessionId, ct);
_joinLinkBackoff.TryRemove(sessionId, out _);
logger.LogInformation("Join link sent for session {SessionId}", sessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send join link for session {SessionId}", sessionId);
var nextAttempt = now.Add(BackoffDuration);
_joinLinkBackoff[sessionId] = nextAttempt;
logger.LogError(
ex,
"Failed to send join link for session {SessionId}, backing off until {Backoff}",
sessionId,
nextAttempt);
}
}
}
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
public interface IPlatformMessenger
{
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support messages with actions.");
Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message updates with actions.");
Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread creation.");
Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support thread deletion.");
Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support message deletion.");
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) =>
throw new NotSupportedException("This platform messenger does not support schedule messages.");
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
@@ -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.0.8</div>
<div class="nav-version">v3.7.1</div>
</div>
</Authorized>
<NotAuthorized>
@@ -0,0 +1,24 @@
@inherits LayoutComponentBase
<div class="public-shell">
<header class="public-topbar">
<a class="public-brand" href="/">
<img src="/logo.png" alt="GM-Relay" />
<span>GM-Relay</span>
</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">
@Body
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
Произошла непредвиденная ошибка.
<a href="." class="reload">Перезагрузить</a>
<span class="dismiss">×</span>
</div>
@@ -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;
}
}
@@ -0,0 +1,108 @@
@page "/group/{GroupId:guid}/completed"
@using GmRelay.Web.Services
@using GmRelay.Web.Services.Portfolio
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedPortfolioService PortfolioService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<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 style="color: var(--text-muted); margin-top: 0.25rem;">
Добавьте проведённые игры в портфолио — система создаст черновик и предложит заполнить детали.
</p>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (sessions is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 55%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%;"></div>
</div>
}
else if (sessions.Count == 0)
{
<div class="glass-card animate-slide-up">
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<div class="empty-state-title">Проведённых сессий пока нет</div>
<p class="empty-state-text">Как только сессии закончатся, они появятся здесь и их можно будет добавить в портфолио.</p>
</div>
</div>
}
else
{
<div class="portfolio-completed-list animate-slide-up">
@foreach (var session in sessions)
{
<div class="portfolio-completed-row">
<div class="portfolio-completed-info">
<a href="/session/@session.Id/history" class="portfolio-completed-title">@session.Title</a>
<span class="portfolio-completed-date">@session.ScheduledAt.FormatMoscow()</span>
</div>
<div class="portfolio-completed-actions">
<button type="button" class="btn-gm btn-gm-primary" disabled="@(creatingDraftSessionId == session.Id)" @onclick="() => AddToPortfolio(session.Id)">
@(creatingDraftSessionId == session.Id ? "⏳..." : "➕ Добавить в портфолио")
</button>
</div>
</div>
}
</div>
}
</div>
@code {
[Parameter] public Guid GroupId { get; set; }
private IReadOnlyList<PortfolioSessionOption>? sessions;
private Guid? creatingDraftSessionId;
private string? errorMessage;
protected override async Task OnInitializedAsync()
{
sessions = await PortfolioService.GetCompletedSessionsForCurrentUserAsync(GroupId);
}
private async Task AddToPortfolio(Guid sessionId)
{
errorMessage = null;
creatingDraftSessionId = sessionId;
try
{
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, sessionId);
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать черновик: " + ex.Message;
}
finally
{
creatingDraftSessionId = null;
}
}
}
@@ -1,10 +1,13 @@
@page "/group/{GroupId:guid}"
@using GmRelay.Web.Services
@using GmRelay.Shared.Domain
@using GmRelay.Web.Services.Portfolio
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedSessionService SessionService
@inject AuthorizedPortfolioService PortfolioService
@inject AuthorizedMembershipService MembershipService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -40,8 +43,8 @@
</span>
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
{
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == manager.ExternalUserId)" @onclick="() => RemoveCoGm(manager.ExternalUserId ?? manager.TelegramId.ToString())">
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
<button type="button" class="btn-gm btn-gm-outline" style="font-size: 0.75rem; padding: 0.25rem 0.5rem;" disabled="@(removingCoGmId == ManagerKey(manager))" @onclick="() => RemoveCoGm(manager)">
@(removingCoGmId == ManagerKey(manager) ? "⏳ Удаляем..." : "Убрать")
</button>
}
}
@@ -52,8 +55,8 @@
<EditForm Model="@coGmModel" OnValidSubmit="AddCoGm">
<div class="batch-bulk-fields">
<div class="gm-form-group">
<label class="gm-form-label">Telegram ID co-GM</label>
<InputNumber @bind-Value="coGmModel.TelegramId" class="gm-form-control" min="1" />
<label class="gm-form-label">@CoGmIdLabel</label>
<InputText @bind-Value="coGmModel.ExternalUserId" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Имя</label>
@@ -61,7 +64,7 @@
</div>
<div class="gm-form-group">
<label class="gm-form-label">Username</label>
<InputText @bind-Value="coGmModel.TelegramUsername" class="gm-form-control" />
<InputText @bind-Value="coGmModel.ExternalUsername" class="gm-form-control" />
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isAddingCoGm">
@@ -72,6 +75,66 @@
</div>
}
@if (publicSettings is not null)
{
<div class="glass-card animate-slide-up public-settings-panel" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Публичная страница клуба</h3>
<p>@publicSettings.PublicSessionCount опубликованных игр без состава игроков и приватных ссылок</p>
</div>
<span class="status-badge @(publicSettings.PublicScheduleEnabled ? "status-success" : "status-neutral")">
@(publicSettings.PublicScheduleEnabled ? "Включена" : "Выключена")
</span>
</div>
<EditForm Model="@publicSettingsModel" OnValidSubmit="SavePublicSettings">
<div class="batch-bulk-fields">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="publicSettingsModel.PublicScheduleEnabled" />
<span>Включить публичное расписание</span>
</label>
<div class="gm-form-hint">Если выключено, публичная страница и ссылки на сессии недоступны.</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="publicSettingsModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например `night-city-club`.</div>
</div>
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingPublicSettings">
@(savingPublicSettings ? "Сохраняем..." : "Сохранить публикацию")
</button>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичную страницу
</a>
}
</div>
</EditForm>
@if (PublicClubUrl is not null && publicSettings.PublicScheduleEnabled)
{
<div class="public-link-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
}
</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;">
@@ -86,6 +149,60 @@
</div>
}
@if (portfolioGames is not null)
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Проведённые приключения</h3>
<p>Черновики и опубликованные приключения для каталога мастера.</p>
</div>
<button type="button" class="btn-gm btn-gm-success" disabled="@isCreatingDraft" @onclick="CreateDraft">
@(isCreatingDraft ? "⏳ Создаём..." : " Создать")
</button>
</div>
@if (portfolioGames.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Приключений пока нет</div>
<p class="empty-state-text">Создайте первый черновик и добавьте проведённые сессии.</p>
</div>
}
else
{
<div class="portfolio-management-list">
@foreach (var game in portfolioGames)
{
<div class="portfolio-management-row">
<div class="portfolio-management-info">
<a href="/portfolio/manage/@game.Id" class="portfolio-management-title">@game.Title</a>
<span class="status-badge @(game.IsPublic ? "status-success" : "status-neutral")">
@(game.IsPublic ? "Опубликовано" : "Черновик")
</span>
</div>
<div class="portfolio-management-meta">
<span class="status-badge status-info">@game.SessionCount игр</span>
<span class="status-badge status-info">@game.MasterCount мастеров</span>
@if (game.PendingReviewCount > 0)
{
<span class="status-badge status-warning">@game.PendingReviewCount на модерации</span>
}
</div>
<div class="portfolio-management-actions">
<a href="/portfolio/manage/@game.Id" class="btn-gm btn-gm-outline">✏️ Изменить</a>
</div>
</div>
}
</div>
}
<div style="margin-top: 0.75rem;">
<a href="/group/@GroupId/completed" class="btn-gm btn-gm-outline">📜 Все проведённые сессии</a>
</div>
</div>
}
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
@@ -201,6 +318,18 @@
</button>
</EditForm>
<div class="batch-publish-row">
<span class="status-badge @(batch.AllSessionsPublic ? "status-success" : batch.PublicSessionCount > 0 ? "status-warning" : "status-neutral")">
@FormatBatchPublication(batch)
</span>
<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">
<select @bind="batch.CloneInterval" class="gm-form-control">
<option value="week">Следующая неделя</option>
@@ -249,6 +378,17 @@
</td>
<td>
<div class="session-table-actions">
<span class="status-badge @GetPublicationStatusClass(session)">@FormatPublicationStatus(session)</span>
<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>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline">
✏️ Изменить
</a>
@@ -337,6 +477,16 @@
</div>
</div>
<div class="session-card-actions">
<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>
}
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
✏️ Изменить
</a>
@@ -398,17 +548,26 @@
private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private WebPublicGroupSettings? publicSettings;
private IReadOnlyList<PortfolioGameSummary>? portfolioGames;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private int pendingApplicationsCount;
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private Guid? publishingBatchId;
private Guid? publishingSessionId;
private string? removingCoGmId;
private bool isAddingCoGm;
private bool isCreatingDraft;
private bool savingPublicSettings;
private string? currentPlatform;
private string? externalUserId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
private PublicSettingsEditModel publicSettingsModel = new();
private Dictionary<Guid, List<WebParticipant>> participantsCache = new();
private HashSet<Guid> expandedSessions = new();
private Guid? kickingParticipantId;
@@ -423,6 +582,7 @@
return;
}
currentPlatform = platform;
await LoadSessions();
}
@@ -442,6 +602,13 @@
return;
}
publicSettings = await SessionService.GetPublicGroupSettingsForCurrentUserAsync(GroupId);
if (publicSettings is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
if (campaignTemplates is null)
{
@@ -449,8 +616,131 @@
return;
}
portfolioGames = await PortfolioService.GetPortfolioGamesForCurrentUserAsync(GroupId);
pendingApplicationsCount = await MembershipService.GetPendingApplicationsCountForCurrentGmAsync(GroupId);
RebuildBatchModels();
RebuildCampaignTemplateModels();
RebuildPublicSettingsModel();
}
private async Task CreateDraft()
{
errorMessage = null;
successMessage = null;
isCreatingDraft = true;
try
{
var portfolioId = await PortfolioService.CreateDraftForCurrentUserAsync(GroupId, null);
Navigation.NavigateTo($"/portfolio/manage/{portfolioId}");
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать черновик: " + ex.Message;
}
finally
{
isCreatingDraft = false;
}
}
private async Task SavePublicSettings()
{
errorMessage = null;
successMessage = null;
savingPublicSettings = true;
try
{
await SessionService.UpdatePublicGroupSettingsForCurrentUserAsync(
GroupId,
publicSettingsModel.PublicSlug,
publicSettingsModel.PublicScheduleEnabled);
successMessage = "Настройки публичной страницы обновлены.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичную страницу: " + ex.Message;
}
finally
{
savingPublicSettings = false;
}
}
private async Task SetBatchPublicationMode(BatchBulkEditModel batch, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
publishingBatchId = batch.BatchId;
try
{
await SessionService.SetBatchPublicationModeForCurrentUserAsync(batch.BatchId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Batch опубликован в общем каталоге.",
PublicationMode.ClubOnly => "Batch доступен только участникам клуба.",
PublicationMode.Both => "Batch опубликован в каталоге и доступен участникам клуба.",
_ => "Batch скрыт из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность batch: " + ex.Message;
}
finally
{
publishingBatchId = null;
}
}
private async Task SetSessionPublicationMode(Guid sessionId, PublicationMode mode)
{
errorMessage = null;
successMessage = null;
publishingSessionId = sessionId;
try
{
await SessionService.SetSessionPublicationModeForCurrentUserAsync(sessionId, mode);
successMessage = mode switch
{
PublicationMode.Catalog => "Сессия опубликована в общем каталоге.",
PublicationMode.ClubOnly => "Сессия доступна только участникам клуба.",
PublicationMode.Both => "Сессия опубликована в каталоге и доступна участникам клуба.",
_ => "Сессия скрыта из публичного расписания."
};
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публичность сессии: " + ex.Message;
}
finally
{
publishingSessionId = null;
}
}
private async Task AddCoGm()
@@ -458,9 +748,16 @@
errorMessage = null;
successMessage = null;
if (!coGmModel.TelegramId.HasValue || coGmModel.TelegramId.Value <= 0)
var coGmExternalUserId = coGmModel.ExternalUserId.Trim();
if (coGmExternalUserId.Length == 0)
{
errorMessage = "Telegram ID co-GM должен быть положительным числом.";
errorMessage = $"{CoGmIdLabel} должен быть заполнен.";
return;
}
if (!IsValidPlatformUserId(CoGmPlatform, coGmExternalUserId))
{
errorMessage = $"{CoGmIdLabel} должен быть положительным числом.";
return;
}
@@ -470,10 +767,10 @@
{
await SessionService.AddCoGmForOwnerAsync(
GroupId,
"Telegram",
coGmModel.TelegramId.Value.ToString(),
CoGmPlatform,
coGmExternalUserId,
coGmModel.DisplayName,
coGmModel.TelegramUsername);
coGmModel.ExternalUsername);
coGmModel = new();
successMessage = "Co-GM добавлен.";
@@ -493,15 +790,17 @@
}
}
private async Task RemoveCoGm(string coGmExternalUserId)
private async Task RemoveCoGm(WebGroupManager manager)
{
errorMessage = null;
successMessage = null;
removingCoGmId = coGmExternalUserId;
removingCoGmId = ManagerKey(manager);
var platform = ManagerPlatform(manager);
var coGmExternalUserId = ManagerExternalUserId(manager);
try
{
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
await SessionService.RemoveCoGmForOwnerAsync(GroupId, platform, coGmExternalUserId);
successMessage = "Co-GM удалён.";
await LoadSessions();
}
@@ -795,7 +1094,15 @@
FirstScheduledAtLocal = firstSession.ScheduledAt.ToMoscow(),
LastScheduledAtLocal = lastSession.ScheduledAt.ToMoscow(),
IntervalDays = InferIntervalDays(orderedSessions),
SessionCount = orderedSessions.Count
SessionCount = orderedSessions.Count,
PublicSessionCount = orderedSessions.Count(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)
@@ -823,6 +1130,20 @@
.ToList() ?? [];
}
private void RebuildPublicSettingsModel()
{
if (publicSettings is null)
{
return;
}
publicSettingsModel = new PublicSettingsEditModel
{
PublicScheduleEnabled = publicSettings.PublicScheduleEnabled,
PublicSlug = publicSettings.PublicSlug ?? ""
};
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
@@ -832,24 +1153,76 @@
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsBatchPublishBusy(BatchBulkEditModel batch) => publishingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
private string? PublicClubUrl =>
string.IsNullOrWhiteSpace(publicSettings?.PublicSlug)
? null
: Navigation.ToAbsoluteUri($"/club/{publicSettings.PublicSlug}").ToString();
private string PublicSessionUrl(Guid sessionId) =>
Navigation.ToAbsoluteUri($"/s/{sessionId}").ToString();
private static string FormatPublicationStatus(WebSession session) =>
session.IsPublic ? "Опубликована" : "Скрыта";
private static string GetPublicationStatusClass(WebSession session) =>
session.IsPublic ? "status-success" : "status-neutral";
private static string FormatBatchPublication(BatchBulkEditModel batch) =>
batch.PublicSessionCount == 0
? "Все игры скрыты"
: batch.PublicSessionCount == batch.SessionCount
? "Все игры опубликованы"
: $"{batch.PublicSessionCount}/{batch.SessionCount} опубликовано";
private string CoGmPlatform =>
string.IsNullOrWhiteSpace(groupManagement?.Group.Platform)
? "Telegram"
: groupManagement.Group.Platform;
private string CoGmIdLabel => $"{CoGmPlatform} ID co-GM";
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
groupManagement?.Managers.FirstOrDefault(manager =>
string.Equals(ManagerPlatform(manager), currentPlatform, StringComparison.OrdinalIgnoreCase) &&
ManagerExternalUserId(manager) == externalUserId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatManager(WebGroupManager manager)
private string FormatManager(WebGroupManager manager)
{
var username = string.IsNullOrWhiteSpace(manager.TelegramUsername)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: "@" + manager.TelegramUsername;
var username = string.IsNullOrWhiteSpace(manager.ExternalUsername)
? manager.TelegramUsername
: manager.ExternalUsername;
var identity = string.IsNullOrWhiteSpace(username)
? $"{ManagerPlatform(manager)} {ManagerExternalUserId(manager)}"
: "@" + username.TrimStart('@');
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {username}";
return $"{FormatRole(manager.Role)} · {manager.DisplayName} · {identity}";
}
private string ManagerPlatform(WebGroupManager manager) =>
string.IsNullOrWhiteSpace(manager.Platform) ? CoGmPlatform : manager.Platform;
private static string ManagerExternalUserId(WebGroupManager manager) =>
string.IsNullOrWhiteSpace(manager.ExternalUserId)
? manager.TelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)
: manager.ExternalUserId;
private string ManagerKey(WebGroupManager manager) =>
$"{ManagerPlatform(manager)}:{ManagerExternalUserId(manager)}";
private static bool IsValidPlatformUserId(string platform, string externalUserId) =>
string.Equals(platform, "Telegram", StringComparison.OrdinalIgnoreCase)
? long.TryParse(externalUserId, out var telegramId) && telegramId > 0
: !string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ||
(ulong.TryParse(externalUserId, out var platformId) && platformId > 0);
private static int InferIntervalDays(IReadOnlyList<WebSession> orderedSessions)
{
if (orderedSessions.Count < 2)
@@ -876,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)}";
@@ -926,6 +1302,9 @@
public DateTime LastScheduledAtLocal { get; init; } = DateTime.Now;
public int IntervalDays { get; set; } = 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";
}
@@ -944,8 +1323,14 @@
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }
public string ExternalUserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public string? TelegramUsername { get; set; }
public string? ExternalUsername { get; set; }
}
private sealed class PublicSettingsEditModel
{
public bool PublicScheduleEnabled { get; set; }
public string? PublicSlug { get; set; }
}
}
+25 -3
View File
@@ -44,9 +44,14 @@
<div class="group-card-icon">🎮</div>
<h3 class="group-card-title">@group.Name</h3>
<p class="group-card-id">ID: @(group.Platform == "Discord" ? group.ExternalGroupId : group.TelegramChatId.ToString())</p>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
@FormatRole(group.ManagerRole)
</span>
<div class="group-card-meta">
<span class="status-badge platform-badge">
@FormatPlatform(group.Platform)
</span>
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")">
@FormatRole(group.ManagerRole)
</span>
</div>
<a href="/group/@group.Id" class="btn-gm btn-gm-primary" style="width: 100%; justify-content: center; margin-top: auto;">
Посмотреть игры →
</a>
@@ -81,6 +86,20 @@
font-family: 'Courier New', monospace;
margin-bottom: 1rem;
}
.group-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.platform-badge {
background: rgba(88, 101, 242, 0.15);
color: #9ea8ff;
border-color: rgba(88, 101, 242, 0.35);
}
</style>
@code {
@@ -104,4 +123,7 @@
private static string FormatRole(string role) =>
GroupManagerRoleExtensions.FromDatabaseValue(role).ToDisplayName();
private static string FormatPlatform(string? platform) =>
string.Equals(platform, "Discord", StringComparison.OrdinalIgnoreCase) ? "Discord" : "Telegram";
}
@@ -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;
}
}
}
@@ -0,0 +1,456 @@
@page "/portfolio/manage/{PortfolioGameId:guid}"
@using GmRelay.Web.Services
@using GmRelay.Web.Services.Portfolio
@using GmRelay.Web.Services.Portfolio.Covers
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@attribute [Authorize]
@inject AuthorizedPortfolioService PortfolioService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
<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>
@if (editor is not null)
{
<p style="color: var(--text-muted); margin-top: 0.25rem;">@editor.Title</p>
}
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
⚠️ @errorMessage
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="gm-alert gm-alert-success" style="margin-bottom: 1rem;">
✅ @successMessage
</div>
}
@if (editor is null)
{
<div class="glass-card" style="padding: 2rem;">
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 50%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 60%;"></div>
</div>
}
else
{
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Параметры публикации</h3>
<p>Управление видимостью и обложкой приключения.</p>
</div>
<span class="status-badge @(editor.IsPublic ? "status-success" : "status-neutral")">
@(editor.IsPublic ? "Опубликовано" : "Черновик")
</span>
</div>
<div class="portfolio-editor-grid">
<div class="portfolio-editor-cover">
@if (!string.IsNullOrEmpty(editor.CoverPath))
{
<img src="@editor.CoverPath" alt="Обложка" class="portfolio-editor-cover-image" />
}
else
{
<div class="portfolio-editor-cover-empty">Обложка не загружена</div>
}
<InputFile OnChange="HandleFileSelected" accept="image/jpeg,image/png,image/webp" class="portfolio-editor-cover-input" />
<button type="button" class="btn-gm btn-gm-outline" disabled="@isUploadingCover" @onclick="TriggerCoverUpload">
@(isUploadingCover ? "⏳ Загружаем..." : "🖼 Заменить обложку")
</button>
</div>
<div class="portfolio-editor-fields">
<EditForm Model="@editorModel" OnValidSubmit="SaveDraft">
<div class="gm-form-group">
<label class="gm-form-label">Название</label>
<InputText @bind-Value="editorModel.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес (slug)</label>
<InputText @bind-Value="editorModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-run".</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Система</label>
<InputText @bind-Value="editorModel.System" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Формат</label>
<InputText @bind-Value="editorModel.Format" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Описание</label>
<InputTextArea @bind-Value="editorModel.Description" class="gm-form-control" />
</div>
<div class="portfolio-editor-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isSaving">
@(isSaving ? "⏳ Сохраняем..." : "💾 Сохранить")
</button>
</div>
</EditForm>
<div class="portfolio-editor-publish-row">
<button type="button" class="btn-gm @(editor.IsPublic ? "btn-gm-outline" : "btn-gm-success")" disabled="@isUpdatingPublication" @onclick="() => SetPublication(!editor.IsPublic)">
@(isUpdatingPublication
? "Обновляем..."
: editor.IsPublic ? "Скрыть из каталога" : "Опубликовать")
</button>
<button type="button" class="btn-gm btn-gm-danger" disabled="@isDeleting" @onclick="DeletePortfolio">
@(isDeleting ? "⏳ Удаляем..." : "🗑 Удалить")
</button>
</div>
</div>
</div>
</div>
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Проведённые сессии</h3>
<p>Отметьте игры, которые вошли в это приключение.</p>
</div>
<span class="status-badge status-info">@editorModel.SessionIds.Count</span>
</div>
<div class="portfolio-option-list">
@foreach (var session in editor.Sessions)
{
<label class="portfolio-option-row">
<input type="checkbox" checked="@session.Selected" @onchange="e => ToggleSession(session.Id, (bool)(e.Value ?? false))" />
<span class="portfolio-option-title">@session.Title</span>
<span class="portfolio-option-meta">@session.ScheduledAt.FormatMoscow()</span>
</label>
}
</div>
</div>
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Мастера приключения</h3>
<p>Выберите мастеров, которые вели это приключение.</p>
</div>
<span class="status-badge status-info">@editorModel.MasterPlayerIds.Count</span>
</div>
<div class="portfolio-option-list">
@foreach (var master in editor.Masters)
{
<label class="portfolio-option-row">
<input type="checkbox" checked="@master.Selected" @onchange="e => ToggleMaster(master.PlayerId, (bool)(e.Value ?? false))" />
<span class="portfolio-option-title">@master.DisplayName</span>
</label>
}
</div>
</div>
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Модерация отзывов</h3>
<p>Одобрите, отклоните или скройте отзывы игроков перед публикацией.</p>
</div>
<span class="status-badge @(editor.Reviews.Any(r => r.ModerationStatus == "Pending") ? "status-warning" : "status-neutral")">
@editor.Reviews.Count(r => r.ModerationStatus == "Pending") на модерации
</span>
</div>
@if (editor.Reviews.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Отзывов пока нет</div>
<p class="empty-state-text">Игроки смогут оставить отзыв после публикации приключения.</p>
</div>
}
else
{
<div class="portfolio-review-moderation">
@foreach (var review in editor.Reviews)
{
<div class="portfolio-review-row">
<div class="portfolio-review-meta">
<span class="portfolio-review-author">@review.AuthorDisplayName</span>
<span class="status-badge @GetReviewStatusClass(review.ModerationStatus)">@TranslateReviewStatus(review.ModerationStatus)</span>
<span class="portfolio-review-date">@review.CreatedAt.ToString("dd.MM.yyyy HH:mm")</span>
</div>
<p class="portfolio-review-body">@review.Body</p>
<div class="portfolio-review-actions">
<button type="button" class="btn-gm btn-gm-success" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Approved"))">
@(moderatingReviewId == review.Id ? "⏳..." : "Одобрить")
</button>
<button type="button" class="btn-gm btn-gm-outline" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Rejected"))">
@(moderatingReviewId == review.Id ? "⏳..." : "Отклонить")
</button>
<button type="button" class="btn-gm btn-gm-danger" disabled="@(moderatingReviewId == review.Id)" @onclick="@(() => Moderate(review.Id, "Hidden"))">
@(moderatingReviewId == review.Id ? "⏳..." : "Скрыть")
</button>
</div>
</div>
}
</div>
}
</div>
}
</div>
@code {
[Parameter] public Guid PortfolioGameId { get; set; }
private PortfolioGameEditor? editor;
private PortfolioEditorModel editorModel = new();
private Guid? groupId;
private string? errorMessage;
private string? successMessage;
private bool isSaving;
private bool isUploadingCover;
private bool isUpdatingPublication;
private bool isDeleting;
private Guid? moderatingReviewId;
private IBrowserFile? pendingCoverFile;
protected override async Task OnParametersSetAsync()
{
await Reload();
}
private async Task Reload()
{
editor = await PortfolioService.GetPortfolioGameForCurrentUserAsync(PortfolioGameId);
if (editor is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
groupId = editor.GroupId;
editorModel = new PortfolioEditorModel
{
Title = editor.Title,
PublicSlug = editor.PublicSlug ?? string.Empty,
Description = editor.Description ?? string.Empty,
System = editor.System ?? string.Empty,
Format = editor.Format ?? string.Empty,
SessionIds = editor.Sessions.Where(s => s.Selected).Select(s => s.Id).ToList(),
MasterPlayerIds = editor.Masters.Where(m => m.Selected).Select(m => m.PlayerId).ToList()
};
}
private void ToggleSession(Guid sessionId, bool isChecked)
{
if (isChecked)
{
if (!editorModel.SessionIds.Contains(sessionId))
{
editorModel.SessionIds.Add(sessionId);
}
}
else
{
editorModel.SessionIds.Remove(sessionId);
}
}
private void ToggleMaster(Guid playerId, bool isChecked)
{
if (isChecked)
{
if (!editorModel.MasterPlayerIds.Contains(playerId))
{
editorModel.MasterPlayerIds.Add(playerId);
}
}
else
{
editorModel.MasterPlayerIds.Remove(playerId);
}
}
private async Task SaveDraft()
{
errorMessage = null;
successMessage = null;
isSaving = true;
try
{
await PortfolioService.UpdateDraftForCurrentUserAsync(
PortfolioGameId,
new PortfolioGameUpdate(
editorModel.Title,
editorModel.PublicSlug,
editorModel.Description,
editorModel.System,
editorModel.Format,
editorModel.SessionIds,
editorModel.MasterPlayerIds));
successMessage = "Черновик сохранён.";
await Reload();
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить: " + ex.Message;
}
finally
{
isSaving = false;
}
}
private void TriggerCoverUpload()
{
// The InputFile control is rendered with a label. No-op click handler kept for symmetry.
}
private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
var file = e.File;
if (file is null)
{
return;
}
pendingCoverFile = file;
errorMessage = null;
successMessage = null;
isUploadingCover = true;
try
{
await using var stream = file.OpenReadStream(LocalPortfolioCoverStorage.MaxBytes);
await PortfolioService.ReplaceCoverForCurrentUserAsync(PortfolioGameId, stream, file.ContentType);
successMessage = "Обложка обновлена.";
await Reload();
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = "Не удалось загрузить обложку: " + ex.Message;
}
finally
{
isUploadingCover = false;
pendingCoverFile = null;
}
}
private async Task SetPublication(bool isPublic)
{
errorMessage = null;
successMessage = null;
isUpdatingPublication = true;
try
{
await PortfolioService.SetPublicationForCurrentUserAsync(PortfolioGameId, isPublic);
successMessage = isPublic ? "Приключение опубликовано." : "Приключение скрыто.";
await Reload();
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить публикацию: " + ex.Message;
}
finally
{
isUpdatingPublication = false;
}
}
private async Task DeletePortfolio()
{
errorMessage = null;
successMessage = null;
isDeleting = true;
try
{
await PortfolioService.DeleteForCurrentUserAsync(PortfolioGameId);
Navigation.NavigateTo(groupId.HasValue ? $"/group/{groupId.Value}" : "/");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить: " + ex.Message;
isDeleting = false;
}
}
private async Task Moderate(Guid reviewId, string moderationStatus)
{
errorMessage = null;
successMessage = null;
moderatingReviewId = reviewId;
try
{
await PortfolioService.ModerateReviewForCurrentUserAsync(PortfolioGameId, reviewId, moderationStatus);
successMessage = "Модерация обновлена.";
await Reload();
}
catch (InvalidOperationException ex)
{
errorMessage = ex.Message;
}
catch (Exception ex)
{
errorMessage = "Не удалось обновить отзыв: " + ex.Message;
}
finally
{
moderatingReviewId = null;
}
}
private static string GetReviewStatusClass(string status) => status switch
{
"Approved" => "status-success",
"Rejected" => "status-danger",
"Hidden" => "status-warning",
_ => "status-neutral"
};
private static string TranslateReviewStatus(string status) => status switch
{
"Approved" => "Одобрен",
"Rejected" => "Отклонён",
"Hidden" => "Скрыт",
_ => "На модерации"
};
private sealed class PortfolioEditorModel
{
public string Title { get; set; } = string.Empty;
public string PublicSlug { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string System { get; set; } = string.Empty;
public string Format { get; set; } = string.Empty;
public List<Guid> SessionIds { get; set; } = new();
public List<Guid> MasterPlayerIds { get; set; } = new();
}
}
+192 -27
View File
@@ -4,28 +4,128 @@
@using Microsoft.Extensions.Configuration
@attribute [Authorize]
@inject ISessionStore SessionStore
@inject AuthorizedSessionService AuthorizedSessionService
@inject IConfiguration Configuration
@inject NavigationManager Navigation
<PageTitle>Профиль — GM-Relay</PageTitle>
<div class="profile-container">
<h1 class="page-title">Профиль</h1>
<div class="page-container">
<ul class="gm-breadcrumb animate-fade-in">
<li><a href="/">Главная</a></li>
<li class="active">Профиль</li>
</ul>
@if (identities is null)
<div class="page-header animate-fade-in">
<h2>Профиль</h2>
<p>Управление публичным профилем мастера и связанными аккаунтами.</p>
</div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<p class="loading-text">Загрузка...</p>
<div class="gm-alert gm-alert-danger animate-fade-in" style="margin-bottom: 1rem;">
@errorMessage
</div>
}
else if (identities.Count == 0)
@if (!string.IsNullOrWhiteSpace(successMessage))
{
<div class="profile-card">
<p>Связанные аккаунты не найдены.</p>
<div class="gm-alert gm-alert-success animate-fade-in" style="margin-bottom: 1rem;">
@successMessage
</div>
}
@if (masterProfile is null)
{
<div class="glass-card animate-fade-in" style="padding: 2rem; margin-bottom: 1rem;">
<div class="skeleton skeleton-text" style="width: 60%; margin-bottom: 1rem;"></div>
<div class="skeleton skeleton-text" style="width: 80%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 40%;"></div>
</div>
}
else
{
<div class="profile-card">
<h2 class="section-title">Связанные аккаунты</h2>
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="profile-card-header">
<div>
<h3>Публичный профиль мастера</h3>
<p>Показывается в каталоге, опубликованных играх и публичных страницах клуба.</p>
</div>
<span class="status-badge @(masterProfile.IsPublic ? "status-success" : "status-neutral")">
@(masterProfile.IsPublic ? "Публичный" : "Скрыт")
</span>
</div>
<EditForm Model="@masterProfileModel" OnValidSubmit="SaveMasterProfile">
<div class="gm-form-group public-toggle-field">
<label class="gm-checkbox-label">
<InputCheckbox @bind-Value="masterProfileModel.IsPublic" />
<span>Опубликовать профиль</span>
</label>
</div>
<div class="profile-form-grid">
<div class="gm-form-group">
<label class="gm-form-label">Имя в публичном профиле</label>
<InputText @bind-Value="masterProfileModel.DisplayName" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Короткий адрес</label>
<InputText @bind-Value="masterProfileModel.PublicSlug" class="gm-form-control" />
<div class="gm-form-hint">Латиница, цифры и дефисы, например "night-city-gm".</div>
</div>
</div>
<div class="gm-form-group">
<label class="gm-form-label">Описание</label>
<InputTextArea @bind-Value="masterProfileModel.Bio" class="gm-form-control master-profile-bio" />
</div>
<div class="public-settings-actions">
<button type="submit" class="btn-gm btn-gm-primary" disabled="@savingMasterProfile">
@(savingMasterProfile ? "Сохраняем..." : "Сохранить профиль")
</button>
@if (PublicMasterProfileUrl is not null)
{
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer" class="btn-gm btn-gm-outline">
Открыть публичный профиль
</a>
}
</div>
</EditForm>
@if (PublicMasterProfileUrl is not null)
{
<div class="public-link-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
}
</div>
}
<div class="glass-card animate-slide-up" style="margin-bottom: 1rem;">
<div class="batch-bulk-header">
<div>
<h3>Связанные аккаунты</h3>
<p>Аккаунты Telegram и Discord, привязанные к вашему профилю.</p>
</div>
</div>
@if (identities is null)
{
<div class="skeleton skeleton-text" style="width: 70%; margin-bottom: 0.75rem;"></div>
<div class="skeleton skeleton-text" style="width: 50%;"></div>
}
else if (identities.Count == 0)
{
<div class="empty-state empty-state-compact">
<div class="empty-state-title">Аккаунты не найдены</div>
<p class="empty-state-text">Привяжите Telegram или Discord, чтобы управлять профилем.</p>
</div>
}
else
{
<ul class="identity-list">
@foreach (var id in identities)
{
@@ -36,7 +136,7 @@
</div>
@if (id.Platform != currentPlatform || id.ExternalUserId != currentExternalUserId)
{
<button class="btn btn-secondary btn-small"
<button class="btn-gm btn-gm-danger"
@onclick="() => Unlink(id.Platform, id.ExternalUserId)"
disabled="@isUnlinking">
Отвязать
@@ -44,25 +144,34 @@
}
else
{
<span class="identity-badge">Текущий</span>
<span class="status-badge status-success">Текущий</span>
}
</li>
}
</ul>
</div>
}
}
</div>
<div class="glass-card animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Добавить аккаунт</h3>
<p>Привяжите дополнительные платформы для входа.</p>
</div>
</div>
<div class="profile-card">
<h2 class="section-title">Добавить аккаунт</h2>
@if (!HasLinkedPlatform("Discord"))
{
<a href="/auth/discord" class="btn btn-primary">
<a href="/auth/discord" class="login-btn-discord" style="margin-bottom: 0.75rem;">
<svg class="login-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Привязать Discord
</a>
}
else
{
<p class="muted-text">Discord уже привязан.</p>
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">Discord уже привязан.</p>
}
@if (currentPlatform == "Discord" && !HasLinkedPlatform("Telegram"))
@@ -78,25 +187,18 @@
}
}
</div>
@if (!string.IsNullOrWhiteSpace(errorMessage))
{
<div class="alert alert-error">@errorMessage</div>
}
@if (!string.IsNullOrWhiteSpace(successMessage))
{
<div class="alert alert-success">@successMessage</div>
}
</div>
@code {
private List<LinkedIdentity>? identities;
private MasterProfileSettings? masterProfile;
private string? currentPlatform;
private string? currentExternalUserId;
private bool isUnlinking;
private bool savingMasterProfile;
private string? errorMessage;
private string? successMessage;
private MasterProfileEditModel masterProfileModel = new();
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
@@ -131,6 +233,7 @@
}
await LoadIdentities();
await LoadMasterProfile();
}
private async Task LoadIdentities()
@@ -152,6 +255,60 @@
}
}
private async Task LoadMasterProfile()
{
try
{
masterProfile = await AuthorizedSessionService.GetMasterProfileSettingsForCurrentUserAsync();
if (masterProfile is not null)
{
masterProfileModel = new MasterProfileEditModel
{
DisplayName = masterProfile.DisplayName,
PublicSlug = masterProfile.PublicSlug ?? string.Empty,
IsPublic = masterProfile.IsPublic,
Bio = masterProfile.Bio ?? string.Empty
};
}
}
catch (Exception ex)
{
errorMessage = $"Не удалось загрузить профиль мастера: {ex.Message}";
}
}
private string? PublicMasterProfileUrl =>
masterProfile?.IsPublic == true && !string.IsNullOrWhiteSpace(masterProfile.PublicSlug)
? Navigation.ToAbsoluteUri($"/gm/{masterProfile.PublicSlug}").ToString()
: null;
private async Task SaveMasterProfile()
{
savingMasterProfile = true;
errorMessage = null;
successMessage = null;
try
{
await AuthorizedSessionService.UpdateMasterProfileSettingsForCurrentUserAsync(
masterProfileModel.PublicSlug,
masterProfileModel.IsPublic,
masterProfileModel.DisplayName,
masterProfileModel.Bio);
successMessage = "Публичный профиль мастера обновлён.";
await LoadMasterProfile();
}
catch (Exception ex)
{
errorMessage = $"Не удалось сохранить профиль мастера: {ex.Message}";
}
finally
{
savingMasterProfile = false;
}
}
private bool HasLinkedPlatform(string platform)
{
return identities?.Any(i => i.Platform == platform) ?? false;
@@ -188,4 +345,12 @@
isUnlinking = false;
}
}
private sealed class MasterProfileEditModel
{
public string DisplayName { get; set; } = string.Empty;
public string PublicSlug { get; set; } = string.Empty;
public bool IsPublic { get; set; }
public string Bio { get; set; } = string.Empty;
}
}
@@ -0,0 +1,265 @@
@page "/club/{Slug}"
@layout PublicLayout
@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
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && club is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Публичная страница не найдена</h1>
<p>Расписание клуба выключено или адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (club is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичное расписание клуба {club.Name} в GM-Relay.")" />
</HeadContent>
<section class="public-hero">
<span class="status-badge status-success">Публичное расписание</span>
<h1>@club.Name</h1>
<p>Открытые игры клуба без состава игроков, личных данных и приватных ссылок.</p>
<div class="public-share-row">
<span>Ссылка клуба</span>
<a href="@PublicClubUrl" target="_blank" rel="noopener noreferrer">@PublicClubUrl</a>
</div>
@if (!string.IsNullOrWhiteSpace(club.MasterProfileSlug))
{
<div class="public-share-row">
<span>Мастер</span>
<a href="@MasterProfilePath(club.MasterProfileSlug)" target="_blank" rel="noopener noreferrer">
@(club.MasterDisplayName ?? "Профиль мастера")
</a>
</div>
}
</section>
@if (club.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда owner или co-GM откроет сессии для публичного расписания, они появятся здесь.</p>
</div>
}
else
{
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>
}
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)
{
<section class="glass-card portfolio-section">
<h2>Завершённые игры клуба</h2>
<p>Публичные портфолио, опубликованные мастерами этого клуба.</p>
<PortfolioCardGrid Games="portfolioGames" />
</section>
}
}
@code {
[Parameter] public string? Slug { get; set; }
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";
private string PublicClubUrl =>
club is null
? Navigation.ToAbsoluteUri($"/club/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/club/{club.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
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, 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;
}
private string PublicSessionPath(Guid sessionId) => $"/s/{sessionId}";
private static string MasterProfilePath(string slug) => $"/gm/{slug}";
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}
@@ -0,0 +1,165 @@
@page "/gm/{Slug}"
@layout PublicLayout
@inject ISessionStore SessionStore
@inject IPortfolioStore PortfolioStore
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor
@using GmRelay.Web.Components.Portfolio
@using GmRelay.Web.Services.Portfolio
<PageTitle>@PageTitleText</PageTitle>
@if (loaded && profile is null)
{
<HeadContent>
<meta name="robots" content="noindex, nofollow" />
</HeadContent>
<section class="public-hero public-hero-compact">
<span class="status-badge status-neutral">Недоступно</span>
<h1>Профиль мастера не найден</h1>
<p>Мастер скрыл профиль или этот короткий адрес больше не используется.</p>
</section>
}
else if (!loaded)
{
<section class="public-hero public-hero-compact">
<div class="skeleton skeleton-text" style="width: 55%; height: 2rem;"></div>
<div class="skeleton skeleton-text" style="width: 75%;"></div>
</section>
}
else if (profile is not null)
{
<HeadContent>
<meta name="description" content="@($"Публичный профиль мастера {profile.DisplayName} в GM-Relay.")" />
</HeadContent>
<section class="public-hero public-hero-compact master-profile-hero">
<span class="status-badge status-success">Мастер</span>
<h1>@profile.DisplayName</h1>
@if (!string.IsNullOrWhiteSpace(profile.Bio))
{
<p>@profile.Bio</p>
}
<div class="public-share-row">
<span>Ссылка профиля</span>
<a href="@PublicMasterProfileUrl" target="_blank" rel="noopener noreferrer">@PublicMasterProfileUrl</a>
</div>
</section>
@if (profile.Clubs.Count > 0)
{
<section class="glass-card master-profile-section">
<h2>Клубы</h2>
<div class="master-profile-club-list">
@foreach (var club in profile.Clubs)
{
<a class="status-badge status-info" href="@($"/club/{club.Slug}")">@club.Name</a>
}
</div>
</section>
}
@if (profile.Sessions.Count == 0)
{
<div class="glass-card public-empty-state">
<h2>Опубликованных игр пока нет</h2>
<p>Когда мастер откроет игры для каталога, они появятся здесь.</p>
</div>
}
else
{
<div class="public-session-list">
@foreach (var session in profile.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.GroupName</span>
<span>@session.ScheduledAt.FormatMoscow()</span>
<span>@FormatSeats(session)</span>
</div>
</div>
<a class="btn-gm btn-gm-outline" href="@($"/s/{session.Id}")">Открыть</a>
</article>
}
</div>
}
@if (portfolioGames.Count > 0)
{
<section class="glass-card portfolio-section">
<h2>Портфолио</h2>
<p>Завершённые игры мастера, открытые для публичного просмотра.</p>
<PortfolioCardGrid Games="portfolioGames" />
</section>
}
}
@code {
[Parameter] public string? Slug { get; set; }
private GmRelay.Web.Services.PublicMasterProfile? profile;
private IReadOnlyList<PublicPortfolioCard> portfolioGames = [];
private bool loaded;
private string PageTitleText => profile is null ? "Профиль мастера — GM-Relay" : $"{profile.DisplayName} — GM-Relay";
private string PublicMasterProfileUrl =>
profile is null
? Navigation.ToAbsoluteUri($"/gm/{Slug}").ToString()
: Navigation.ToAbsoluteUri($"/gm/{profile.Slug}").ToString();
protected override async Task OnParametersSetAsync()
{
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, viewerPlayerId);
portfolioGames = trimmedSlug is null
? []
: await PortfolioStore.GetPublicPortfolioGamesForMasterAsync(trimmedSlug);
loaded = true;
}
private static string FormatSeats(WebPublicSession session)
{
var seats = session.MaxPlayers.HasValue
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
: $"{session.ActivePlayerCount} игроков";
return session.WaitlistedPlayerCount > 0
? $"{seats}, ожидание {session.WaitlistedPlayerCount}"
: seats;
}
private static string GetStatusClass(string status) => status switch
{
SessionStatus.Confirmed => "status-success",
SessionStatus.ConfirmationSent => "status-warning",
SessionStatus.Planned => "status-info",
_ => "status-neutral"
};
private static string TranslateStatus(string status) => status switch
{
SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждем подтверждения",
SessionStatus.Confirmed => "Подтверждено",
_ => status
};
}

Some files were not shown because too many files have changed in this diff Show More