# GM-Relay - C4 Model

## Level 1: System Context

```mermaid
C4Context
    title GM-Relay System Context

    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, 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, 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")
    Rel(gmrelay, discord, "Send/edit schedule, RSVP, reminder, and reschedule messages")
    Rel(gmrelay, postgres, "SQL via Npgsql and Dapper")
```

## Level 2: Container

```mermaid
C4Container
    title GM-Relay Containers

    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, 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, 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")
    Rel(discordBot, discord, "REST send/edit/reply calls")
    Rel(bot, shared, "Uses shared renderers and join/leave handlers")
    Rel(discordBot, shared, "Uses shared renderers, scheduler, and platform-neutral handlers")
    Rel(web, shared, "Uses shared domain and rendering models")
    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

```mermaid
C4Component
    title Platform-Neutral Session Interactions

    Container_Boundary(shared, "GmRelay.Shared") {
        Component(join, "JoinSessionHandler", "Feature handler", "Adds players as Active or Waitlisted with session row locking")
        Component(leave, "LeaveSessionHandler", "Feature handler", "Removes players and promotes the first waitlisted player when capacity allows")
        Component(rsvp, "HandleRsvpHandler", "Feature handler", "Updates RSVP state and emits platform-neutral RSVP outcomes")
        Component(scheduler, "SessionSchedulerService", "Background service", "Triggers confirmation, reminder, and join-link notifications per platform")
        Component(updateLock, "ScheduleMessageUpdateLock", "In-memory keyed lock", "Serializes DB changes and schedule message edits per platform message")
        Component(renderer, "SessionBatchViewBuilder", "Renderer model builder", "Builds platform-neutral schedule views and actions")
    }

    Component(healthCheck, "DiscordHealthCheckHostedService", ":8082", "Healthcheck для Docker Compose")

    Container_Boundary(discordBot, "GmRelay.DiscordBot") {
        Component(discordModule, "DiscordSessionInteractionModule", "NetCord component module", "Maps join_session/leave_session/rsvp buttons to neutral commands")
        Component(discordMessenger, "DiscordPlatformMessenger", "IPlatformMessenger", "Sends and edits Discord schedule, RSVP, reminder, join-link, and reschedule messages")
    }

    Container_Boundary(bot, "GmRelay.Bot") {
        Component(updateRouter, "UpdateRouter", "Telegram adapter", "Maps callback queries to neutral commands")
        Component(telegramMessenger, "TelegramPlatformMessenger", "IPlatformMessenger", "Sends and edits Telegram schedule, RSVP, reminder, join-link, and reschedule messages")
    }

    ContainerDb(db, "PostgreSQL")
    System_Ext(telegram, "Telegram Bot API")
    System_Ext(discord, "Discord Gateway and REST API")

    Rel(discord, discordModule, "Button interaction")
    Rel(discordModule, join, "JoinSessionCommand")
    Rel(discordModule, leave, "LeaveSessionCommand")
    Rel(discordModule, rsvp, "HandleRsvpCommand")
    Rel(discordModule, discord, "Deferred ephemeral reply, then modify response")
    Rel(updateRouter, join, "JoinSessionCommand")
    Rel(updateRouter, leave, "LeaveSessionCommand")
    Rel(updateRouter, rsvp, "HandleRsvpCommand")
    Rel(join, updateLock, "Acquire by PlatformMessageRef")
    Rel(leave, updateLock, "Acquire by PlatformMessageRef")
    Rel(join, db, "SELECT FOR UPDATE, INSERT participant")
    Rel(leave, db, "SELECT FOR UPDATE, DELETE/promote participant")
    Rel(rsvp, db, "Update RSVP and load notification recipients")
    Rel(scheduler, db, "Load due session triggers")
    Rel(join, renderer, "Build updated schedule view")
    Rel(leave, renderer, "Build updated schedule view")
    Rel(join, discordMessenger, "Update Discord schedule when command is Discord")
    Rel(leave, discordMessenger, "Update Discord schedule when command is Discord")
    Rel(join, telegramMessenger, "Update Telegram schedule when command is Telegram")
    Rel(leave, telegramMessenger, "Update Telegram schedule when command is Telegram")
    Rel(rsvp, discordMessenger, "Update Discord confirmation and outcomes")
    Rel(rsvp, telegramMessenger, "Update Telegram confirmation and outcomes")
    Rel(scheduler, discordMessenger, "Send Discord scheduler notifications")
    Rel(scheduler, telegramMessenger, "Send Telegram scheduler notifications")
    Rel(discordMessenger, discord, "REST send/edit/DM + ephemeral text")
    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`.
