186 lines
12 KiB
Markdown
186 lines
12 KiB
Markdown
# 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`.
|