feat(web): private club showcases with membership flow (v3.7.0, issue #110) #119

Closed
Toutsu wants to merge 0 commits from feat/issue-110-private-club-showcases into main
Owner

Summary

Implements Issue #110: game masters can now publish sessions exclusively to a club's private showcase, gated behind a member application and approval flow. Replaces the binary is_public column with a 4-state publication_mode enum, and adds a club_memberships table with a Pending/Active/Rejected/Left lifecycle.

What's new

Data model (V030 migration)

  • New club_memberships table with status/role CHECK constraints, partial unique index ux_club_memberships_one_active ensuring only one Active row per (group, player).
  • sessions.publication_mode VARCHAR(20) replacing is_public; CHECK constraint over None/Catalog/ClubOnly/Both.
  • Recreates partial indexes ix_sessions_public_schedule and ix_sessions_showcase against the new column.
  • portfolio_games.publication_mode (default Both) for parity with the upcoming portfolio feature.

Domain

  • GmRelay.Shared.Domain.PublicationMode enum with ToDatabaseValue / FromDatabaseValue / IsVisibleInCatalog / IsVisibleToClubMembers.

Services

  • ISessionStore gains 12 membership/showcase methods.
  • AuthorizedMembershipService (new) owns the membership flow with GM-only approve/reject authorization.
  • SessionService: member-aware SQL for GetPublicClubBySlugAsync, GetPublicSessionAsync, GetPublicMasterProfileBySlugAsync, and GetPublicSessionsForMasterAsyncClubOnly rows are only visible to Active members of the group.
  • AuthorizedSessionService: SetSessionPublic* / SetBatchPublic* renamed to SetSessionPublicationMode* / SetBatchPublicationMode*.

UI

  • MyClubMemberships.razor/profile/memberships (active, pending, history; leave/cancel buttons).
  • ClubApplications.razor/group/{GroupId}/applications (owner/co-GM only; approve/reject).
  • PublicClub.razor — public/members-only sections with apply & login CTAs; passes viewerPlayerId to the store.
  • PublicMasterProfile.razor — same member-aware resolution.
  • EditSession.razor and GroupDetails.razor — replaced the boolean toggle with a 4-state <select>.
  • NavMenu.razor — new "Мои клубы" link, version bumped to v3.7.0.
  • PublicLayout.razor — new "Клубы" link to /showcase.
  • GroupDetails.razor — "Заявки участников (N)" card linking to the applications page when N > 0.

Tests (493 total, all passing)

  • 4 new test files: PublicationModeTests, ClubMembershipsTests, ClubShowcaseSourceTests, AuthorizedMembershipServiceTests.
  • Updated: PublicClubPagesTests (filter clause and migration assertions), AuthorizedSessionServiceTests / AuthorizedPortfolioServiceTests (FakeSessionStore now matches the new ISessionStore surface), CampaignTemplatesNavigationTests (version assertion).

Versioning

  • Directory.Build.props, compose.yaml, .gitea/workflows/deploy.yml, src/GmRelay.Web/Components/Layout/NavMenu.razor all bumped from 3.6.03.7.0.

Out of scope

  • Telegram/Discord bot commands for membership — Web-only by design (same rationale as the existing SetSessionPublic* surface).
  • Inheritance of publication_mode from reviews (only portfolio_games was extended, as agreed in the issue).

Manual verification

  1. Apply V030 locally — \d club_memberships, \d sessions (no is_public, has publication_mode), \di ix_sessions_public_schedule (partial index on publication_mode IN ('Catalog','Both')).
  2. As a GM: /group/{id} → 4-state selector for a session; /group/{id}/applications shows pending applications.
  3. As a player: /club/{slug} → "Подать заявку" with a message; after GM approval, the "Игры для участников клуба" section appears; ClubOnly sessions are visible only to approved members.
  4. As anonymous: /club/{slug} never exposes the members-only list, and the public showcase filters ClubOnly rows out.
  5. /showcase for any visitor never includes ClubOnly sessions.
  6. grep -rn "is_public" src/ — should be empty in sessions (still present on master_profiles.is_public from V028, which is intentional).

🤖 Generated with Claude Code

## Summary Implements Issue #110: game masters can now publish sessions exclusively to a club's private showcase, gated behind a member application and approval flow. Replaces the binary `is_public` column with a 4-state `publication_mode` enum, and adds a `club_memberships` table with a Pending/Active/Rejected/Left lifecycle. ## What's new **Data model (V030 migration)** - New `club_memberships` table with status/role CHECK constraints, partial unique index `ux_club_memberships_one_active` ensuring only one Active row per (group, player). - `sessions.publication_mode` VARCHAR(20) replacing `is_public`; CHECK constraint over `None/Catalog/ClubOnly/Both`. - Recreates partial indexes `ix_sessions_public_schedule` and `ix_sessions_showcase` against the new column. - `portfolio_games.publication_mode` (default `Both`) for parity with the upcoming portfolio feature. **Domain** - `GmRelay.Shared.Domain.PublicationMode` enum with `ToDatabaseValue` / `FromDatabaseValue` / `IsVisibleInCatalog` / `IsVisibleToClubMembers`. **Services** - `ISessionStore` gains 12 membership/showcase methods. - `AuthorizedMembershipService` (new) owns the membership flow with GM-only approve/reject authorization. - `SessionService`: member-aware SQL for `GetPublicClubBySlugAsync`, `GetPublicSessionAsync`, `GetPublicMasterProfileBySlugAsync`, and `GetPublicSessionsForMasterAsync` — `ClubOnly` rows are only visible to Active members of the group. - `AuthorizedSessionService`: `SetSessionPublic*` / `SetBatchPublic*` renamed to `SetSessionPublicationMode*` / `SetBatchPublicationMode*`. **UI** - `MyClubMemberships.razor` — `/profile/memberships` (active, pending, history; leave/cancel buttons). - `ClubApplications.razor` — `/group/{GroupId}/applications` (owner/co-GM only; approve/reject). - `PublicClub.razor` — public/members-only sections with apply & login CTAs; passes `viewerPlayerId` to the store. - `PublicMasterProfile.razor` — same member-aware resolution. - `EditSession.razor` and `GroupDetails.razor` — replaced the boolean toggle with a 4-state `<select>`. - `NavMenu.razor` — new "Мои клубы" link, version bumped to v3.7.0. - `PublicLayout.razor` — new "Клубы" link to `/showcase`. - `GroupDetails.razor` — "Заявки участников (N)" card linking to the applications page when N > 0. **Tests** (493 total, all passing) - 4 new test files: `PublicationModeTests`, `ClubMembershipsTests`, `ClubShowcaseSourceTests`, `AuthorizedMembershipServiceTests`. - Updated: `PublicClubPagesTests` (filter clause and migration assertions), `AuthorizedSessionServiceTests` / `AuthorizedPortfolioServiceTests` (FakeSessionStore now matches the new `ISessionStore` surface), `CampaignTemplatesNavigationTests` (version assertion). ## Versioning - `Directory.Build.props`, `compose.yaml`, `.gitea/workflows/deploy.yml`, `src/GmRelay.Web/Components/Layout/NavMenu.razor` all bumped from `3.6.0` → `3.7.0`. ## Out of scope - Telegram/Discord bot commands for membership — Web-only by design (same rationale as the existing `SetSessionPublic*` surface). - Inheritance of `publication_mode` from reviews (only `portfolio_games` was extended, as agreed in the issue). ## Manual verification 1. Apply V030 locally — `\d club_memberships`, `\d sessions` (no `is_public`, has `publication_mode`), `\di ix_sessions_public_schedule` (partial index on `publication_mode IN ('Catalog','Both')`). 2. As a GM: `/group/{id}` → 4-state selector for a session; `/group/{id}/applications` shows pending applications. 3. As a player: `/club/{slug}` → "Подать заявку" with a message; after GM approval, the "Игры для участников клуба" section appears; `ClubOnly` sessions are visible only to approved members. 4. As anonymous: `/club/{slug}` never exposes the members-only list, and the public showcase filters `ClubOnly` rows out. 5. `/showcase` for any visitor never includes `ClubOnly` sessions. 6. `grep -rn "is_public" src/` — should be empty in `sessions` (still present on `master_profiles.is_public` from V028, which is intentional). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Toutsu added 1 commit 2026-06-03 11:11:23 +03:00
feat(web): add private club showcases with membership flow (v3.7.0)
PR Checks / test-and-build (pull_request) Successful in 7m28s
6cb2fbe610
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>
Toutsu added 1 commit 2026-06-03 11:34:59 +03:00
fix(web): allow cancelling pending applications; drop contradictory message guard
PR Checks / test-and-build (pull_request) Successful in 7m50s
22e9859fdf
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>
Toutsu closed this pull request 2026-06-03 11:47:29 +03:00
Some checks are pending
PR Checks / test-and-build (pull_request) Successful in 7m50s

Pull request closed

Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Toutsu/GmRelayBot#119