- Discord OAuth 2.0 login flow (identify + guilds scopes) - Platform-agnostic auth via (platform, external_user_id) - Cookie Authentication with ClaimsPrincipal - Razor Pages updated to *ForCurrentUserAsync APIs - CSRF protection for Discord OAuth - V019 migration for audit log platform identity - Version bumped to 2.8.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,13 @@ TELEGRAM_MINI_APP_URL=
|
|||||||
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
|
# Можно получить в Discord Developer Portal (https://discord.com/developers/applications)
|
||||||
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
|
DISCORD_BOT_TOKEN=YOUR_DISCORD_BOT_TOKEN_HERE
|
||||||
|
|
||||||
|
# Discord OAuth (для Web Dashboard)
|
||||||
|
# Client ID и Secret из OAuth2 раздела Discord Developer Portal
|
||||||
|
# Redirect URI должен указывать на /auth/discord/callback вашего домена
|
||||||
|
DISCORD_CLIENT_ID=YOUR_DISCORD_CLIENT_ID_HERE
|
||||||
|
DISCORD_CLIENT_SECRET=YOUR_DISCORD_CLIENT_SECRET_HERE
|
||||||
|
DISCORD_REDIRECT_URI=https://your-domain.example/auth/discord/callback
|
||||||
|
|
||||||
# Пароль для базы данных PostgreSQL
|
# Пароль для базы данных PostgreSQL
|
||||||
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
POSTGRES_PASSWORD=StrongPasswordForDatabase
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 2.7.2
|
VERSION: 2.8.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
@@ -113,6 +113,9 @@ jobs:
|
|||||||
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
|
echo "DISCORD_BOT_TOKEN=${{ secrets.DISCORD_BOT_TOKEN }}" >> .env
|
||||||
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
|
echo "TELEGRAM_BOT_USERNAME=${{ secrets.TELEGRAM_BOT_USERNAME }}" >> .env
|
||||||
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
|
echo "TELEGRAM_MINI_APP_URL=${{ secrets.TELEGRAM_MINI_APP_URL }}" >> .env
|
||||||
|
echo "DISCORD_CLIENT_ID=${{ secrets.DISCORD_CLIENT_ID }}" >> .env
|
||||||
|
echo "DISCORD_CLIENT_SECRET=${{ secrets.DISCORD_CLIENT_SECRET }}" >> .env
|
||||||
|
echo "DISCORD_REDIRECT_URI=${{ secrets.DISCORD_REDIRECT_URI }}" >> .env
|
||||||
|
|
||||||
- name: Deploy Containers
|
- name: Deploy Containers
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>2.7.2</Version>
|
<Version>2.8.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v2.7.2`.
|
**Текущая версия:** `v2.8.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.7.2
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:2.8.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.7.2
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:2.8.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -84,7 +84,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:2.7.2
|
image: git.codeanddice.ru/toutsu/gmrelay-web:2.8.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -94,6 +94,9 @@ services:
|
|||||||
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN:?Set TELEGRAM_BOT_TOKEN in .env}"
|
||||||
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
|
- "Telegram__BotUsername=${TELEGRAM_BOT_USERNAME:?Set TELEGRAM_BOT_USERNAME in .env}"
|
||||||
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
- "Telegram__MiniAppUrl=${TELEGRAM_MINI_APP_URL:-}"
|
||||||
|
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
||||||
|
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
||||||
|
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
||||||
ports:
|
ports:
|
||||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- V019: Rename session_audit_log.actor_telegram_id to actor_external_user_id
|
||||||
|
-- =============================================================
|
||||||
|
-- Scope: Support platform-agnostic audit log identity.
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
ALTER TABLE session_audit_log
|
||||||
|
ADD COLUMN actor_external_user_id VARCHAR(255);
|
||||||
|
|
||||||
|
UPDATE session_audit_log
|
||||||
|
SET actor_external_user_id = actor_telegram_id::TEXT
|
||||||
|
WHERE actor_external_user_id IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE session_audit_log
|
||||||
|
ALTER COLUMN actor_external_user_id SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE session_audit_log
|
||||||
|
DROP COLUMN actor_telegram_id;
|
||||||
@@ -39,9 +39,19 @@
|
|||||||
<div class="nav-footer">
|
<div class="nav-footer">
|
||||||
<div class="nav-user">
|
<div class="nav-user">
|
||||||
<div class="nav-user-avatar">
|
<div class="nav-user-avatar">
|
||||||
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
|
@if (!string.IsNullOrWhiteSpace(context.User.GetAvatarUrl()))
|
||||||
|
{
|
||||||
|
<img src="@context.User.GetAvatarUrl()" alt="" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@(context.User.Identity?.Name?.Substring(0, 1).ToUpper() ?? "?")
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="nav-user-info">
|
||||||
|
<span class="nav-user-name">@context.User.Identity?.Name</span>
|
||||||
|
<span class="nav-user-platform">@GetPlatformLabel(context.User)</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="nav-user-name">@context.User.Identity?.Name</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="/auth/logout" method="post">
|
<form action="/auth/logout" method="post">
|
||||||
@@ -56,7 +66,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v2.7.2</div>
|
<div class="nav-version">v2.8.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -79,4 +89,7 @@
|
|||||||
|
|
||||||
private void ToggleMenu() => isOpen = !isOpen;
|
private void ToggleMenu() => isOpen = !isOpen;
|
||||||
private void CloseMenu() => isOpen = false;
|
private void CloseMenu() => isOpen = false;
|
||||||
|
|
||||||
|
private static string GetPlatformLabel(System.Security.Claims.ClaimsPrincipal user) =>
|
||||||
|
user.TryGetDiscordId(out _) ? "Discord" : "Telegram";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@
|
|||||||
private Guid selectedGroupId;
|
private Guid selectedGroupId;
|
||||||
private Guid? deletingTemplateId;
|
private Guid? deletingTemplateId;
|
||||||
private bool isCreatingTemplate;
|
private bool isCreatingTemplate;
|
||||||
private long telegramId;
|
private string? externalUserId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CampaignTemplateEditModel templateModel = new();
|
private CampaignTemplateEditModel templateModel = new();
|
||||||
@@ -195,13 +195,13 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (!authState.User.TryGetTelegramId(out telegramId))
|
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
groups = await SessionService.GetGroupsForCurrentUserAsync();
|
||||||
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
|
selectedGroupId = groups.FirstOrDefault()?.Id ?? Guid.Empty;
|
||||||
|
|
||||||
if (selectedGroupId != Guid.Empty)
|
if (selectedGroupId != Guid.Empty)
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
campaignTemplates = null;
|
campaignTemplates = null;
|
||||||
campaignTemplateModels = [];
|
campaignTemplateModels = [];
|
||||||
|
|
||||||
var templates = await SessionService.GetCampaignTemplatesForGmAsync(selectedGroupId, telegramId);
|
var templates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(selectedGroupId);
|
||||||
if (templates is null)
|
if (templates is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
@@ -260,9 +260,8 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.CreateCampaignTemplateForGmAsync(
|
await SessionService.CreateCampaignTemplateForCurrentUserAsync(
|
||||||
selectedGroupId,
|
selectedGroupId,
|
||||||
telegramId,
|
|
||||||
new CreateCampaignTemplateRequest(
|
new CreateCampaignTemplateRequest(
|
||||||
templateModel.Name,
|
templateModel.Name,
|
||||||
templateModel.Title,
|
templateModel.Title,
|
||||||
@@ -298,7 +297,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
|
await SessionService.DeleteCampaignTemplateForCurrentUserAsync(template.Id);
|
||||||
successMessage = "Шаблон кампании удалён.";
|
successMessage = "Шаблон кампании удалён.";
|
||||||
await LoadTemplates();
|
await LoadTemplates();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,13 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
|
session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
|
|
||||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||||
|
|
||||||
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
await SessionService.UpdateSessionForCurrentUserAsync(SessionId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
catch (SessionAccessDeniedException)
|
||||||
|
|||||||
@@ -40,8 +40,8 @@
|
|||||||
</span>
|
</span>
|
||||||
@if (groupManagement.CurrentUserIsOwner && manager.Role == GroupManagerRoleExtensions.CoGmValue)
|
@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.TelegramId)" @onclick="() => RemoveCoGm(manager.TelegramId)">
|
<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.TelegramId ? "⏳ Удаляем..." : "Убрать")
|
@(removingCoGmId == manager.ExternalUserId ? "⏳ Удаляем..." : "Убрать")
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,9 +403,9 @@
|
|||||||
private Guid? promotingSessionId;
|
private Guid? promotingSessionId;
|
||||||
private Guid? processingBatchId;
|
private Guid? processingBatchId;
|
||||||
private Guid? processingTemplateId;
|
private Guid? processingTemplateId;
|
||||||
private long? removingCoGmId;
|
private string? removingCoGmId;
|
||||||
private bool isAddingCoGm;
|
private bool isAddingCoGm;
|
||||||
private long telegramId;
|
private string? externalUserId;
|
||||||
private string? errorMessage;
|
private string? errorMessage;
|
||||||
private string? successMessage;
|
private string? successMessage;
|
||||||
private CoGmEditModel coGmModel = new();
|
private CoGmEditModel coGmModel = new();
|
||||||
@@ -417,7 +417,7 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (!authState.User.TryGetTelegramId(out telegramId))
|
if (!authState.User.TryGetPlatformIdentity(out var platform, out externalUserId))
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
@@ -428,21 +428,21 @@
|
|||||||
|
|
||||||
private async Task LoadSessions()
|
private async Task LoadSessions()
|
||||||
{
|
{
|
||||||
groupManagement = await SessionService.GetGroupManagementForGmAsync(GroupId, telegramId);
|
groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
|
||||||
if (groupManagement is null)
|
if (groupManagement is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
sessions = await SessionService.GetUpcomingSessionsForCurrentUserAsync(GroupId);
|
||||||
if (sessions is null)
|
if (sessions is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
|
campaignTemplates = await SessionService.GetCampaignTemplatesForCurrentUserAsync(GroupId);
|
||||||
if (campaignTemplates is null)
|
if (campaignTemplates is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
@@ -470,8 +470,8 @@
|
|||||||
{
|
{
|
||||||
await SessionService.AddCoGmForOwnerAsync(
|
await SessionService.AddCoGmForOwnerAsync(
|
||||||
GroupId,
|
GroupId,
|
||||||
telegramId,
|
"Telegram",
|
||||||
coGmModel.TelegramId.Value,
|
coGmModel.TelegramId.Value.ToString(),
|
||||||
coGmModel.DisplayName,
|
coGmModel.DisplayName,
|
||||||
coGmModel.TelegramUsername);
|
coGmModel.TelegramUsername);
|
||||||
|
|
||||||
@@ -493,15 +493,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RemoveCoGm(long coGmTelegramId)
|
private async Task RemoveCoGm(string coGmExternalUserId)
|
||||||
{
|
{
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
successMessage = null;
|
successMessage = null;
|
||||||
removingCoGmId = coGmTelegramId;
|
removingCoGmId = coGmExternalUserId;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.RemoveCoGmForOwnerAsync(GroupId, telegramId, coGmTelegramId);
|
await SessionService.RemoveCoGmForOwnerAsync(GroupId, "Telegram", coGmExternalUserId);
|
||||||
successMessage = "Co-GM удалён.";
|
successMessage = "Co-GM удалён.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -527,7 +527,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
|
await SessionService.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
catch (SessionAccessDeniedException)
|
catch (SessionAccessDeniedException)
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
loadingParticipantsSessionId = sessionId;
|
loadingParticipantsSessionId = sessionId;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var participants = await SessionService.GetSessionParticipantsForGmAsync(sessionId, telegramId);
|
var participants = await SessionService.GetSessionParticipantsForCurrentUserAsync(sessionId);
|
||||||
participantsCache[sessionId] = participants ?? [];
|
participantsCache[sessionId] = participants ?? [];
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.RemovePlayerFromSessionForGmAsync(sessionId, telegramId, participantId);
|
await SessionService.RemovePlayerFromSessionForCurrentUserAsync(sessionId, participantId);
|
||||||
participantsCache.Remove(sessionId);
|
participantsCache.Remove(sessionId);
|
||||||
successMessage = "Игрок исключён.";
|
successMessage = "Игрок исключён.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
@@ -658,10 +658,9 @@
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SessionService.UpdateBatchDetailsForGmAsync(batch.BatchId, telegramId, batch.Title, batch.JoinLink);
|
await SessionService.UpdateBatchDetailsForCurrentUserAsync(batch.BatchId, batch.Title, batch.JoinLink);
|
||||||
await SessionService.UpdateBatchNotificationModeForGmAsync(
|
await SessionService.UpdateBatchNotificationModeForCurrentUserAsync(
|
||||||
batch.BatchId,
|
batch.BatchId,
|
||||||
telegramId,
|
|
||||||
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
|
SessionNotificationModeExtensions.FromDatabaseValue(batch.NotificationMode));
|
||||||
successMessage = "Настройки batch обновлены.";
|
successMessage = "Настройки batch обновлены.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
@@ -696,7 +695,7 @@
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
var utcTime = new DateTimeOffset(batch.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||||
await SessionService.RescheduleBatchForGmAsync(batch.BatchId, telegramId, utcTime, batch.IntervalDays);
|
await SessionService.RescheduleBatchForCurrentUserAsync(batch.BatchId, utcTime, batch.IntervalDays);
|
||||||
successMessage = "Расписание пачки обновлено.";
|
successMessage = "Расписание пачки обновлено.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -726,7 +725,7 @@
|
|||||||
? BatchCloneInterval.NextMonth
|
? BatchCloneInterval.NextMonth
|
||||||
: BatchCloneInterval.NextWeek;
|
: BatchCloneInterval.NextWeek;
|
||||||
|
|
||||||
var clonedBatch = await SessionService.CloneBatchForGmAsync(batch.BatchId, telegramId, interval);
|
var clonedBatch = await SessionService.CloneBatchForCurrentUserAsync(batch.BatchId, interval);
|
||||||
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
|
successMessage = $"Пачка склонирована: {clonedBatch.SessionCount} игр.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -759,7 +758,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
|
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForCurrentUserAsync(template.Id, utcTime);
|
||||||
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
|
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
|
||||||
await LoadSessions();
|
await LoadSessions();
|
||||||
}
|
}
|
||||||
@@ -836,7 +835,7 @@
|
|||||||
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
private bool IsTemplateBusy(CampaignTemplateUsageModel template) => processingTemplateId == template.Id;
|
||||||
|
|
||||||
private string CurrentUserRole =>
|
private string CurrentUserRole =>
|
||||||
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
|
groupManagement?.Managers.FirstOrDefault(manager => manager.ExternalUserId == externalUserId)?.Role
|
||||||
?? GroupManagerRoleExtensions.CoGmValue;
|
?? GroupManagerRoleExtensions.CoGmValue;
|
||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using System.Security.Claims
|
@using System.Security.Claims
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
@inject ISessionStore SessionStore
|
@inject AuthorizedSessionService SessionService
|
||||||
@inject AuthenticationStateProvider AuthStateProvider
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
@inject NavigationManager Navigation
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="player-info">
|
<div class="player-info">
|
||||||
<span class="player-name">@s.DisplayName</span>
|
<span class="player-name">@s.DisplayName</span>
|
||||||
@if (!string.IsNullOrEmpty(s.TelegramUsername))
|
@if (!string.IsNullOrEmpty(s.ExternalUsername))
|
||||||
{
|
{
|
||||||
<span class="player-username">@@@s.TelegramUsername</span>
|
<span class="player-username">@@@s.ExternalUsername</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -171,21 +171,20 @@
|
|||||||
Navigation.NavigateTo("/login");
|
Navigation.NavigateTo("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var telegramIdClaim = user.FindFirst("telegram_id")?.Value
|
if (!user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (!long.TryParse(telegramIdClaim, out var telegramId))
|
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/login");
|
Navigation.NavigateTo("/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!await SessionStore.IsGroupManagerAsync(GroupId, telegramId))
|
var groupManagement = await SessionService.GetGroupManagementForCurrentUserAsync(GroupId);
|
||||||
|
if (groupManagement is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stats = await SessionStore.GetGroupAttendanceStatsAsync(GroupId) ?? new();
|
stats = await SessionService.GetGroupAttendanceStatsForCurrentUserAsync(GroupId) ?? new();
|
||||||
UpdateSortedStats();
|
UpdateSortedStats();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
<div class="glass-card group-card">
|
<div class="glass-card group-card">
|
||||||
<div class="group-card-icon">🎮</div>
|
<div class="group-card-icon">🎮</div>
|
||||||
<h3 class="group-card-title">@group.Name</h3>
|
<h3 class="group-card-title">@group.Name</h3>
|
||||||
<p class="group-card-id">ID: @group.TelegramChatId</p>
|
<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;">
|
<span class="status-badge @(group.ManagerRole == GroupManagerRoleExtensions.OwnerValue ? "status-success" : "status-info")" style="align-self: flex-start; margin-bottom: 1rem;">
|
||||||
@FormatRole(group.ManagerRole)
|
@FormatRole(group.ManagerRole)
|
||||||
</span>
|
</span>
|
||||||
@@ -93,13 +93,13 @@
|
|||||||
var user = authState.User;
|
var user = authState.User;
|
||||||
userName = user.Identity?.Name ?? "Мастер Игры";
|
userName = user.Identity?.Name ?? "Мастер Игры";
|
||||||
|
|
||||||
if (!user.TryGetTelegramId(out var telegramId))
|
if (!user.TryGetPlatformIdentity(out _, out _))
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
groups = await SessionService.GetGroupsForGmAsync(telegramId);
|
groups = await SessionService.GetGroupsForCurrentUserAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatRole(string role) =>
|
private static string FormatRole(string role) =>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<img src="logo.png" alt="GM-Relay" class="login-logo" />
|
<img src="logo.png" alt="GM-Relay" class="login-logo" />
|
||||||
<h1 class="login-title">GM-Relay</h1>
|
<h1 class="login-title">GM-Relay</h1>
|
||||||
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
|
<p class="login-subtitle">Войдите для управления игровыми сессиями</p>
|
||||||
|
|
||||||
@if (Navigation.Uri.Contains("error=auth_failed"))
|
@if (Navigation.Uri.Contains("error=auth_failed"))
|
||||||
{
|
{
|
||||||
@@ -20,6 +20,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div id="telegram-login-container"></div>
|
<div id="telegram-login-container"></div>
|
||||||
|
|
||||||
|
<div class="login-divider">
|
||||||
|
<span>или</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/auth/discord" class="login-btn login-btn-discord">
|
||||||
|
<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-.028 14.09 14.09 0 0 0 1.226-1.994.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.086 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.086 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
Войти через Discord
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
|
<td>@entry.ChangedAt.ToString("dd.MM.yyyy HH:mm") UTC</td>
|
||||||
<td>@entry.ActorName (@entry.ActorTelegramId)</td>
|
<td>@entry.ActorName (@entry.ActorExternalUserId)</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
|
<span class="status-badge @(GetBadgeClass(entry.ChangeType))">
|
||||||
@GetChangeTypeLabel(entry.ChangeType)
|
@GetChangeTypeLabel(entry.ChangeType)
|
||||||
@@ -82,13 +82,13 @@
|
|||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
if (!authState.User.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var session = await SessionService.GetSessionForGmAsync(SessionId, telegramId);
|
var session = await SessionService.GetSessionForCurrentUserAsync(SessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
Navigation.NavigateTo("/access-denied");
|
Navigation.NavigateTo("/access-denied");
|
||||||
@@ -97,7 +97,7 @@
|
|||||||
|
|
||||||
sessionTitle = session.Title;
|
sessionTitle = session.Title;
|
||||||
groupId = session.GroupId;
|
groupId = session.GroupId;
|
||||||
entries = await SessionService.GetSessionHistoryForGmAsync(SessionId, telegramId);
|
entries = await SessionService.GetSessionHistoryForCurrentUserAsync(SessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetChangeTypeLabel(string changeType) => changeType switch
|
private string GetChangeTypeLabel(string changeType) => changeType switch
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace GmRelay.Web;
|
||||||
|
|
||||||
|
public sealed class DiscordOAuthOptions
|
||||||
|
{
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
public string ClientSecret { get; set; } = string.Empty;
|
||||||
|
public string RedirectUri { get; set; } = string.Empty;
|
||||||
|
public string[] Scopes { get; set; } = ["identify", "guilds"];
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(ClientId))
|
||||||
|
throw new InvalidOperationException("Discord:ClientId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(ClientSecret))
|
||||||
|
throw new InvalidOperationException("Discord:ClientSecret is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(RedirectUri))
|
||||||
|
throw new InvalidOperationException("Discord:RedirectUri is required.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using GmRelay.Web;
|
||||||
using GmRelay.Web.Components;
|
using GmRelay.Web.Components;
|
||||||
using GmRelay.Web.Health;
|
using GmRelay.Web.Health;
|
||||||
using GmRelay.Web.Services;
|
using GmRelay.Web.Services;
|
||||||
@@ -7,6 +8,7 @@ using Microsoft.AspNetCore.DataProtection;
|
|||||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Telegram.Bot;
|
using Telegram.Bot;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -16,6 +18,12 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Add Aspire service defaults
|
// Add Aspire service defaults
|
||||||
builder.AddServiceDefaults();
|
builder.AddServiceDefaults();
|
||||||
|
|
||||||
|
// Add HttpClient
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
|
// Add HttpContextAccessor for platform-agnostic identity resolution
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
// Add health checks
|
// Add health checks
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddCheck<NpgsqlHealthCheck>("npgsql");
|
.AddCheck<NpgsqlHealthCheck>("npgsql");
|
||||||
@@ -29,6 +37,8 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
|||||||
|
|
||||||
// Add Services
|
// Add Services
|
||||||
builder.Services.AddSingleton<TelegramAuthService>();
|
builder.Services.AddSingleton<TelegramAuthService>();
|
||||||
|
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
||||||
|
builder.Services.AddSingleton<DiscordAuthService>();
|
||||||
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
builder.Services.AddSingleton<ISessionStore, SessionService>();
|
||||||
builder.Services.AddScoped<AuthorizedSessionService>();
|
builder.Services.AddScoped<AuthorizedSessionService>();
|
||||||
builder.Services.AddScoped<CalendarSubscriptionService>();
|
builder.Services.AddScoped<CalendarSubscriptionService>();
|
||||||
@@ -174,6 +184,56 @@ app.MapPost("/auth/logout", async (HttpContext context) =>
|
|||||||
return Results.Redirect("/");
|
return Results.Redirect("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Discord OAuth endpoints
|
||||||
|
app.MapGet("/auth/discord", (HttpContext context, DiscordAuthService discordAuth) =>
|
||||||
|
{
|
||||||
|
var state = Guid.NewGuid().ToString("N");
|
||||||
|
context.Response.Cookies.Append("__DiscordOAuthState", state, new CookieOptions
|
||||||
|
{
|
||||||
|
HttpOnly = true,
|
||||||
|
Secure = true,
|
||||||
|
SameSite = SameSiteMode.Strict,
|
||||||
|
MaxAge = TimeSpan.FromMinutes(5)
|
||||||
|
});
|
||||||
|
var url = discordAuth.BuildAuthorizeUrl(state);
|
||||||
|
return Results.Redirect(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/auth/discord/callback", async (
|
||||||
|
HttpContext context,
|
||||||
|
DiscordAuthService discordAuth,
|
||||||
|
ISessionStore sessionStore) =>
|
||||||
|
{
|
||||||
|
var code = context.Request.Query["code"].ToString();
|
||||||
|
var state = context.Request.Query["state"].ToString();
|
||||||
|
var storedState = context.Request.Cookies["__DiscordOAuthState"];
|
||||||
|
|
||||||
|
context.Response.Cookies.Delete("__DiscordOAuthState");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(code) ||
|
||||||
|
string.IsNullOrWhiteSpace(state) ||
|
||||||
|
!CryptographicOperations.FixedTimeEquals(
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(state),
|
||||||
|
System.Text.Encoding.UTF8.GetBytes(storedState ?? string.Empty)))
|
||||||
|
{
|
||||||
|
return Results.Redirect("/login?error=auth_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await discordAuth.ExchangeCodeAsync(code);
|
||||||
|
if (user is null)
|
||||||
|
return Results.Redirect("/login?error=auth_failed");
|
||||||
|
|
||||||
|
await sessionStore.UpsertDiscordUserAsync(user.Id, user.DisplayName, user.AvatarUrl);
|
||||||
|
|
||||||
|
var authProperties = new AuthenticationProperties { IsPersistent = true };
|
||||||
|
await context.SignInAsync(
|
||||||
|
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||||
|
CreateDiscordPrincipal(user.Id, user.DisplayName, user.AvatarUrl),
|
||||||
|
authProperties);
|
||||||
|
|
||||||
|
return Results.Redirect("/");
|
||||||
|
});
|
||||||
|
|
||||||
// Public calendar subscription endpoint (no auth required)
|
// Public calendar subscription endpoint (no auth required)
|
||||||
app.MapGet("/calendar/{token}.ics", async (
|
app.MapGet("/calendar/{token}.ics", async (
|
||||||
string token,
|
string token,
|
||||||
@@ -200,11 +260,29 @@ static ClaimsPrincipal CreateTelegramPrincipal(long telegramId, string name)
|
|||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
new(ClaimTypes.NameIdentifier, telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
new(ClaimTypes.Name, name),
|
new(ClaimTypes.Name, name),
|
||||||
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
new("TelegramId", telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)),
|
||||||
|
new("Platform", "Telegram")
|
||||||
};
|
};
|
||||||
|
|
||||||
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
return new ClaimsPrincipal(claimsIdentity);
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ClaimsPrincipal CreateDiscordPrincipal(string discordId, string name, string? avatarUrl)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(ClaimTypes.NameIdentifier, discordId),
|
||||||
|
new(ClaimTypes.Name, name),
|
||||||
|
new("DiscordId", discordId),
|
||||||
|
new("Platform", "Discord")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(avatarUrl))
|
||||||
|
claims.Add(new Claim("AvatarUrl", avatarUrl));
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||||
|
return new ClaimsPrincipal(claimsIdentity);
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record TelegramWebAppAuthRequest(string InitData);
|
public sealed record TelegramWebAppAuthRequest(string InitData);
|
||||||
|
|||||||
@@ -1,182 +1,237 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
public sealed class AuthorizedSessionService(ISessionStore sessionStore, IHttpContextAccessor httpContextAccessor)
|
||||||
{
|
{
|
||||||
public Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId) =>
|
private (string Platform, string ExternalUserId, string Name)? GetCurrentIdentity()
|
||||||
sessionStore.GetGroupsForGmAsync(gmId);
|
|
||||||
|
|
||||||
public async Task<WebGroupManagement?> GetGroupManagementForGmAsync(Guid groupId, long gmId)
|
|
||||||
{
|
{
|
||||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
var user = httpContextAccessor.HttpContext?.User;
|
||||||
{
|
if (user is null || !user.TryGetPlatformIdentity(out var platform, out var externalUserId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var name = user.FindFirst(ClaimTypes.Name)?.Value ?? externalUserId;
|
||||||
|
return (platform, externalUserId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<List<WebGameGroup>> GetGroupsForCurrentUserAsync()
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return Task.FromResult(new List<WebGameGroup>());
|
||||||
|
|
||||||
|
return sessionStore.GetGroupsForUserAsync(identity.Value.Platform, identity.Value.ExternalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WebGroupManagement?> GetGroupManagementForCurrentUserAsync(Guid groupId)
|
||||||
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
var group = await sessionStore.GetGroupAsync(groupId);
|
var group = await sessionStore.GetGroupAsync(groupId);
|
||||||
if (group is null)
|
if (group is null)
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
var managers = await sessionStore.GetGroupManagersAsync(groupId);
|
var managers = await sessionStore.GetGroupManagersAsync(groupId);
|
||||||
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, gmId);
|
var isOwner = await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId);
|
||||||
return new WebGroupManagement(group, managers, isOwner);
|
return new WebGroupManagement(group, managers, isOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<WebSession>?> GetUpcomingSessionsForGmAsync(Guid groupId, long gmId)
|
public async Task<List<WebSession>?> GetUpcomingSessionsForCurrentUserAsync(Guid groupId)
|
||||||
{
|
{
|
||||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
var identity = GetCurrentIdentity();
|
||||||
{
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
return await sessionStore.GetUpcomingSessionsAsync(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebSession?> GetSessionForGmAsync(Guid sessionId, long gmId)
|
public async Task<WebSession?> GetSessionForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
var session = await sessionStore.GetSessionAsync(sessionId);
|
var session = await sessionStore.GetSessionAsync(sessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
return await sessionStore.IsGroupManagerAsync(session.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebSessionBatch?> GetBatchForGmAsync(Guid batchId, long gmId)
|
public async Task<WebSessionBatch?> GetBatchForCurrentUserAsync(Guid batchId)
|
||||||
{
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
var batch = await sessionStore.GetBatchAsync(batchId);
|
var batch = await sessionStore.GetBatchAsync(batchId);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return await GroupBelongsToGmAsync(batch.GroupId, gmId) ? batch : null;
|
return await sessionStore.IsGroupManagerAsync(batch.GroupId, identity.Value.Platform, identity.Value.ExternalUserId) ? batch : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
public async Task UpdateSessionForCurrentUserAsync(Guid sessionId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
|
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
|
||||||
|
|
||||||
if (session.Title != title)
|
if (session.Title != title)
|
||||||
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Title", session.Title, title);
|
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Title", session.Title, title);
|
||||||
if (session.ScheduledAt != scheduledAt)
|
if (session.ScheduledAt != scheduledAt)
|
||||||
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
|
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Time", session.ScheduledAt.ToString("O"), scheduledAt.ToString("O"));
|
||||||
if (session.JoinLink != joinLink)
|
if (session.JoinLink != joinLink)
|
||||||
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "Link", session.JoinLink, joinLink);
|
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "Link", session.JoinLink, joinLink);
|
||||||
if (session.MaxPlayers != maxPlayers)
|
if (session.MaxPlayers != maxPlayers)
|
||||||
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
|
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "MaxPlayers", session.MaxPlayers?.ToString(), maxPlayers?.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
|
public async Task PromoteWaitlistedPlayerForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
|
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
|
||||||
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "WaitlistPromote", null, null);
|
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "WaitlistPromote", null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateBatchDetailsForGmAsync(Guid batchId, long gmId, string title, string joinLink)
|
public async Task UpdateBatchDetailsForCurrentUserAsync(Guid batchId, string title, string joinLink)
|
||||||
{
|
{
|
||||||
var batch = await GetBatchForGmAsync(batchId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, gmId);
|
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
|
await sessionStore.UpdateBatchDetailsAsync(batchId, batch.GroupId, title.Trim(), joinLink.Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateBatchNotificationModeForGmAsync(Guid batchId, long gmId, SessionNotificationMode notificationMode)
|
public async Task UpdateBatchNotificationModeForCurrentUserAsync(Guid batchId, SessionNotificationMode notificationMode)
|
||||||
{
|
{
|
||||||
var batch = await GetBatchForGmAsync(batchId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, gmId);
|
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
|
await sessionStore.UpdateBatchNotificationModeAsync(batchId, batch.GroupId, notificationMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RescheduleBatchForGmAsync(Guid batchId, long gmId, DateTime firstScheduledAt, int intervalDays)
|
public async Task RescheduleBatchForCurrentUserAsync(Guid batchId, DateTime firstScheduledAt, int intervalDays)
|
||||||
{
|
{
|
||||||
if (intervalDays <= 0)
|
if (intervalDays <= 0)
|
||||||
{
|
{
|
||||||
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
|
throw new ArgumentOutOfRangeException(nameof(intervalDays), intervalDays, "Interval must be greater than zero.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var batch = await GetBatchForGmAsync(batchId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, gmId);
|
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
|
await sessionStore.RescheduleBatchAsync(batchId, batch.GroupId, firstScheduledAt, intervalDays);
|
||||||
await sessionStore.LogSessionChangeAsync(batchId, gmId, "ГМ", "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
|
await sessionStore.LogSessionChangeAsync(batchId, identity.Value.ExternalUserId, identity.Value.Name, "BatchRescheduled", batch.FirstScheduledAt.ToString("O"), firstScheduledAt.ToString("O"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebSessionBatch> CloneBatchForGmAsync(Guid batchId, long gmId, BatchCloneInterval interval)
|
public async Task<WebSessionBatch> CloneBatchForCurrentUserAsync(Guid batchId, BatchCloneInterval interval)
|
||||||
{
|
{
|
||||||
var batch = await GetBatchForGmAsync(batchId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var batch = await GetBatchForCurrentUserAsync(batchId);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, gmId);
|
throw new SessionAccessDeniedException(batchId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
|
return await sessionStore.CloneBatchAsync(batchId, batch.GroupId, interval);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForGmAsync(Guid groupId, long gmId)
|
public async Task<List<WebCampaignTemplate>?> GetCampaignTemplatesForCurrentUserAsync(Guid groupId)
|
||||||
{
|
{
|
||||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
var identity = GetCurrentIdentity();
|
||||||
{
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return await sessionStore.GetCampaignTemplatesAsync(groupId);
|
return await sessionStore.GetCampaignTemplatesAsync(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebCampaignTemplate> CreateCampaignTemplateForGmAsync(
|
public async Task<WebCampaignTemplate> CreateCampaignTemplateForCurrentUserAsync(
|
||||||
Guid groupId,
|
Guid groupId,
|
||||||
long gmId,
|
|
||||||
CreateCampaignTemplateRequest request)
|
CreateCampaignTemplateRequest request)
|
||||||
{
|
{
|
||||||
if (!await GroupBelongsToGmAsync(groupId, gmId))
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(groupId, gmId);
|
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
|
var normalizedRequest = NormalizeCampaignTemplateRequest(request);
|
||||||
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
|
return await sessionStore.CreateCampaignTemplateAsync(groupId, normalizedRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteCampaignTemplateForGmAsync(Guid templateId, long gmId)
|
public async Task DeleteCampaignTemplateForCurrentUserAsync(Guid templateId)
|
||||||
{
|
{
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
|
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
|
||||||
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
|
if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(templateId, gmId);
|
throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
|
await sessionStore.DeleteCampaignTemplateAsync(templateId, template.GroupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForGmAsync(
|
public async Task<WebSessionBatch> CreateBatchFromCampaignTemplateForCurrentUserAsync(
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
long gmId,
|
|
||||||
DateTime firstScheduledAt)
|
DateTime firstScheduledAt)
|
||||||
{
|
{
|
||||||
if (firstScheduledAt <= DateTime.UtcNow)
|
if (firstScheduledAt <= DateTime.UtcNow)
|
||||||
@@ -184,89 +239,112 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
|||||||
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
|
throw new ArgumentOutOfRangeException(nameof(firstScheduledAt), firstScheduledAt, "First scheduled time must be in the future.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
|
var template = await sessionStore.GetCampaignTemplateAsync(templateId);
|
||||||
if (template is null || !await GroupBelongsToGmAsync(template.GroupId, gmId))
|
if (template is null || !await sessionStore.IsGroupManagerAsync(template.GroupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(templateId, gmId);
|
throw new SessionAccessDeniedException(templateId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
|
return await sessionStore.CreateBatchFromTemplateAsync(templateId, template.GroupId, firstScheduledAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername)
|
public async Task AddCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
|
||||||
{
|
{
|
||||||
if (coGmTelegramId <= 0)
|
if (string.IsNullOrWhiteSpace(coGmExternalUserId))
|
||||||
{
|
{
|
||||||
throw new ArgumentOutOfRangeException(nameof(coGmTelegramId), coGmTelegramId, "Telegram id must be greater than zero.");
|
throw new ArgumentException("External user id must not be empty.", nameof(coGmExternalUserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ownerTelegramId == coGmTelegramId)
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
if (identity.Value.ExternalUserId == coGmExternalUserId && identity.Value.Platform == coGmPlatform)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Owner is already a group manager.");
|
throw new InvalidOperationException("Owner is already a group manager.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
|
if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
|
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var normalizedName = string.IsNullOrWhiteSpace(displayName)
|
var normalizedName = string.IsNullOrWhiteSpace(displayName)
|
||||||
? $"Telegram {coGmTelegramId.ToString(System.Globalization.CultureInfo.InvariantCulture)}"
|
? $"{coGmPlatform} {coGmExternalUserId}"
|
||||||
: displayName.Trim();
|
: displayName.Trim();
|
||||||
var normalizedUsername = string.IsNullOrWhiteSpace(telegramUsername)
|
var normalizedUsername = string.IsNullOrWhiteSpace(externalUsername)
|
||||||
? null
|
? null
|
||||||
: telegramUsername.Trim().TrimStart('@');
|
: externalUsername.Trim().TrimStart('@');
|
||||||
|
|
||||||
await sessionStore.AddGroupCoGmAsync(groupId, ownerTelegramId, coGmTelegramId, normalizedName, normalizedUsername);
|
await sessionStore.AddGroupCoGmAsync(
|
||||||
|
groupId,
|
||||||
|
identity.Value.Platform, identity.Value.ExternalUserId,
|
||||||
|
coGmPlatform, coGmExternalUserId,
|
||||||
|
normalizedName, normalizedUsername);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveCoGmForOwnerAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId)
|
public async Task RemoveCoGmForOwnerAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
|
||||||
{
|
{
|
||||||
if (!await sessionStore.IsGroupOwnerAsync(groupId, ownerTelegramId))
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupOwnerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(groupId, ownerTelegramId);
|
throw new SessionAccessDeniedException(groupId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmTelegramId);
|
await sessionStore.RemoveGroupCoGmAsync(groupId, coGmPlatform, coGmExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<WebParticipant>?> GetSessionParticipantsForGmAsync(Guid sessionId, long gmId)
|
public async Task<List<WebParticipant>?> GetSessionParticipantsForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return await sessionStore.GetSessionParticipantsAsync(sessionId);
|
return await sessionStore.GetSessionParticipantsAsync(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForGmAsync(Guid sessionId, long gmId)
|
public async Task<List<SessionAuditLogEntry>?> GetSessionHistoryForCurrentUserAsync(Guid sessionId)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return await sessionStore.GetSessionHistoryAsync(sessionId);
|
return await sessionStore.GetSessionHistoryAsync(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemovePlayerFromSessionForGmAsync(Guid sessionId, long gmId, Guid participantId)
|
public async Task RemovePlayerFromSessionForCurrentUserAsync(Guid sessionId, Guid participantId)
|
||||||
{
|
{
|
||||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
throw new InvalidOperationException("User is not authenticated.");
|
||||||
|
|
||||||
|
var session = await GetSessionForCurrentUserAsync(sessionId);
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
throw new SessionAccessDeniedException(sessionId, identity.Value.ExternalUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
|
await sessionStore.RemovePlayerFromSessionAsync(sessionId, session.GroupId, participantId);
|
||||||
await sessionStore.LogSessionChangeAsync(sessionId, gmId, "ГМ", "PlayerRemoved", participantId.ToString(), null);
|
await sessionStore.LogSessionChangeAsync(sessionId, identity.Value.ExternalUserId, identity.Value.Name, "PlayerRemoved", participantId.ToString(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
public async Task<List<PlayerAttendanceStats>?> GetGroupAttendanceStatsForCurrentUserAsync(Guid groupId)
|
||||||
{
|
{
|
||||||
return await sessionStore.IsGroupManagerAsync(groupId, gmId);
|
var identity = GetCurrentIdentity();
|
||||||
|
if (identity is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (!await sessionStore.IsGroupManagerAsync(groupId, identity.Value.Platform, identity.Value.ExternalUserId))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await sessionStore.GetGroupAttendanceStatsAsync(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
|
private static CreateCampaignTemplateRequest NormalizeCampaignTemplateRequest(CreateCampaignTemplateRequest request)
|
||||||
|
|||||||
@@ -6,4 +6,37 @@ public static class ClaimsPrincipalExtensions
|
|||||||
{
|
{
|
||||||
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
|
public static bool TryGetTelegramId(this ClaimsPrincipal user, out long telegramId) =>
|
||||||
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
|
long.TryParse(user.FindFirst("TelegramId")?.Value, out telegramId);
|
||||||
|
|
||||||
|
public static bool TryGetDiscordId(this ClaimsPrincipal user, out string? discordId)
|
||||||
|
{
|
||||||
|
discordId = user.FindFirst("DiscordId")?.Value;
|
||||||
|
return !string.IsNullOrWhiteSpace(discordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryGetPlatformIdentity(this ClaimsPrincipal user, out string platform, out string externalUserId)
|
||||||
|
{
|
||||||
|
platform = string.Empty;
|
||||||
|
externalUserId = string.Empty;
|
||||||
|
|
||||||
|
var platformClaim = user.FindFirst("Platform")?.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(platformClaim))
|
||||||
|
{
|
||||||
|
platform = platformClaim;
|
||||||
|
externalUserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
|
||||||
|
return !string.IsNullOrWhiteSpace(externalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for legacy Telegram users before Platform claim was added
|
||||||
|
if (TryGetTelegramId(user, out var telegramId))
|
||||||
|
{
|
||||||
|
platform = "Telegram";
|
||||||
|
externalUserId = telegramId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? GetAvatarUrl(this ClaimsPrincipal user) =>
|
||||||
|
user.FindFirst("AvatarUrl")?.Value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
|
public sealed class DiscordAuthService(IHttpClientFactory httpClientFactory, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
private readonly DiscordOAuthOptions _options = configuration.GetSection("Discord").Get<DiscordOAuthOptions>() ?? new DiscordOAuthOptions();
|
||||||
|
|
||||||
|
public string BuildAuthorizeUrl(string state)
|
||||||
|
{
|
||||||
|
_options.Validate();
|
||||||
|
var scopes = string.Join(" ", _options.Scopes);
|
||||||
|
return $"https://discord.com/oauth2/authorize?client_id={_options.ClientId}&redirect_uri={Uri.EscapeDataString(_options.RedirectUri)}&response_type=code&scope={Uri.EscapeDataString(scopes)}&state={Uri.EscapeDataString(state)}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DiscordUser?> ExchangeCodeAsync(string code)
|
||||||
|
{
|
||||||
|
_options.Validate();
|
||||||
|
var client = httpClientFactory.CreateClient();
|
||||||
|
|
||||||
|
var tokenResponse = await ExchangeCodeForTokenAsync(client, code);
|
||||||
|
if (tokenResponse is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await FetchUserProfileAsync(client, tokenResponse.AccessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<DiscordTokenResponse?> ExchangeCodeForTokenAsync(HttpClient client, string code)
|
||||||
|
{
|
||||||
|
var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "authorization_code",
|
||||||
|
["code"] = code,
|
||||||
|
["redirect_uri"] = _options.RedirectUri,
|
||||||
|
["client_id"] = _options.ClientId,
|
||||||
|
["client_secret"] = _options.ClientSecret
|
||||||
|
});
|
||||||
|
|
||||||
|
var response = await client.PostAsync("https://discord.com/api/oauth2/token", content);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<DiscordTokenResponse>(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<DiscordUser?> FetchUserProfileAsync(HttpClient client, string accessToken)
|
||||||
|
{
|
||||||
|
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||||
|
var response = await client.GetAsync("https://discord.com/api/users/@me");
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
return JsonSerializer.Deserialize<DiscordUser>(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record DiscordTokenResponse(
|
||||||
|
[property: JsonPropertyName("access_token")] string AccessToken,
|
||||||
|
[property: JsonPropertyName("token_type")] string TokenType,
|
||||||
|
[property: JsonPropertyName("expires_in")] int ExpiresIn,
|
||||||
|
[property: JsonPropertyName("scope")] string Scope);
|
||||||
|
|
||||||
|
public sealed record DiscordUser(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("username")] string Username,
|
||||||
|
[property: JsonPropertyName("discriminator")] string Discriminator,
|
||||||
|
[property: JsonPropertyName("avatar")] string? Avatar,
|
||||||
|
[property: JsonPropertyName("email")] string? Email)
|
||||||
|
{
|
||||||
|
public string DisplayName =>
|
||||||
|
Discriminator == "0" ? Username : $"{Username}#{Discriminator}";
|
||||||
|
|
||||||
|
public string? AvatarUrl =>
|
||||||
|
!string.IsNullOrWhiteSpace(Avatar)
|
||||||
|
? $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.png"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -5,33 +5,31 @@ namespace GmRelay.Web.Services;
|
|||||||
public sealed record PlayerAttendanceStats(
|
public sealed record PlayerAttendanceStats(
|
||||||
Guid PlayerId,
|
Guid PlayerId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? TelegramUsername,
|
string? ExternalUsername,
|
||||||
long TotalSessions,
|
long TotalSessions,
|
||||||
long ConfirmedCount,
|
long ConfirmedCount,
|
||||||
long DeclinedCount,
|
long DeclinedCount,
|
||||||
long NoResponseCount,
|
long NoResponseCount,
|
||||||
long WaitlistedCount,
|
long WaitlistedCount,
|
||||||
long CancellationAffectedCount,
|
long CancellationAffectedCount,
|
||||||
decimal AttendanceRate
|
decimal AttendanceRate);
|
||||||
);
|
|
||||||
|
|
||||||
public sealed record SessionAuditLogEntry(
|
public sealed record SessionAuditLogEntry(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SessionId,
|
Guid SessionId,
|
||||||
long ActorTelegramId,
|
string ActorExternalUserId,
|
||||||
string ActorName,
|
string ActorName,
|
||||||
string ChangeType,
|
string ChangeType,
|
||||||
string? OldValue,
|
string? OldValue,
|
||||||
string? NewValue,
|
string? NewValue,
|
||||||
DateTime ChangedAt
|
DateTime ChangedAt);
|
||||||
);
|
|
||||||
|
|
||||||
public interface ISessionStore
|
public interface ISessionStore
|
||||||
{
|
{
|
||||||
Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId);
|
Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId);
|
||||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||||
Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId);
|
Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId);
|
Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId);
|
||||||
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId);
|
||||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||||
@@ -47,11 +45,12 @@ public interface ISessionStore
|
|||||||
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
|
Task<WebCampaignTemplate> CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request);
|
||||||
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
|
Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId);
|
||||||
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
|
Task<WebSessionBatch> CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt);
|
||||||
Task AddGroupCoGmAsync(Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername);
|
Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername);
|
||||||
Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId);
|
Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId);
|
||||||
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
|
Task<List<WebParticipant>> GetSessionParticipantsAsync(Guid sessionId);
|
||||||
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
|
Task RemovePlayerFromSessionAsync(Guid sessionId, Guid groupId, Guid participantId);
|
||||||
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
|
Task<List<PlayerAttendanceStats>> GetGroupAttendanceStatsAsync(Guid groupId);
|
||||||
Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue);
|
Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue);
|
||||||
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
|
Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId);
|
||||||
|
Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace GmRelay.Web.Services;
|
namespace GmRelay.Web.Services;
|
||||||
|
|
||||||
public sealed class SessionAccessDeniedException(Guid sessionId, long gmId)
|
public sealed class SessionAccessDeniedException(Guid sessionId, string externalUserId)
|
||||||
: InvalidOperationException($"Session '{sessionId}' is not accessible for GM '{gmId}'.");
|
: InvalidOperationException($"Session '{sessionId}' is not accessible for user '{externalUserId}'.");
|
||||||
|
|||||||
@@ -10,16 +10,32 @@ namespace GmRelay.Web.Services;
|
|||||||
public sealed record WebGameGroup(
|
public sealed record WebGameGroup(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
long TelegramChatId,
|
long TelegramChatId,
|
||||||
|
string? ExternalGroupId,
|
||||||
string Name,
|
string Name,
|
||||||
long GmTelegramId,
|
string? Platform,
|
||||||
string ManagerRole = GroupManagerRoleExtensions.OwnerValue);
|
string ManagerRole = GroupManagerRoleExtensions.OwnerValue)
|
||||||
|
{
|
||||||
|
public long GmTelegramId { get; init; }
|
||||||
|
|
||||||
|
public WebGameGroup(Guid id, long telegramChatId, string name, long gmTelegramId)
|
||||||
|
: this(id, telegramChatId, null, name, null)
|
||||||
|
{
|
||||||
|
GmTelegramId = gmTelegramId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record WebGroupManager(
|
public sealed record WebGroupManager(
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
|
string? ExternalUserId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? TelegramUsername,
|
string? TelegramUsername,
|
||||||
|
string? ExternalUsername,
|
||||||
string Role,
|
string Role,
|
||||||
DateTime AddedAt);
|
DateTime AddedAt)
|
||||||
|
{
|
||||||
|
public WebGroupManager(long telegramId, string displayName, string? telegramUsername, string role, DateTime addedAt)
|
||||||
|
: this(telegramId, null, displayName, telegramUsername, null, role, addedAt) { }
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record WebGroupManagement(
|
public sealed record WebGroupManagement(
|
||||||
WebGameGroup Group,
|
WebGameGroup Group,
|
||||||
@@ -44,8 +60,10 @@ public sealed record WebSession(
|
|||||||
public sealed record WebParticipant(
|
public sealed record WebParticipant(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
long TelegramId,
|
long TelegramId,
|
||||||
|
string? ExternalUserId,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? TelegramUsername,
|
string? TelegramUsername,
|
||||||
|
string? ExternalUsername,
|
||||||
string RsvpStatus,
|
string RsvpStatus,
|
||||||
string RegistrationStatus,
|
string RegistrationStatus,
|
||||||
bool IsGm,
|
bool IsGm,
|
||||||
@@ -83,23 +101,25 @@ public sealed class SessionService(
|
|||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
ILogger<SessionService> logger) : ISessionStore
|
ILogger<SessionService> logger) : ISessionStore
|
||||||
{
|
{
|
||||||
public async Task<List<WebGameGroup>> GetGroupsForGmAsync(long gmId)
|
public async Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return (await conn.QueryAsync<WebGameGroup>(
|
return (await conn.QueryAsync<WebGameGroup>(
|
||||||
"""
|
"""
|
||||||
SELECT g.id,
|
SELECT g.id,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name,
|
g.name,
|
||||||
g.gm_telegram_id AS GmTelegramId,
|
g.platform AS Platform,
|
||||||
gm.role AS ManagerRole
|
gm.role AS ManagerRole
|
||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
JOIN game_groups g ON g.id = gm.group_id
|
JOIN game_groups g ON g.id = gm.group_id
|
||||||
WHERE p.telegram_id = @GmId
|
WHERE p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""",
|
""",
|
||||||
new { GmId = gmId })).ToList();
|
new { Platform = platform, ExternalUserId = externalUserId })).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
public async Task<WebGameGroup?> GetGroupAsync(Guid groupId)
|
||||||
@@ -109,8 +129,9 @@ public sealed class SessionService(
|
|||||||
"""
|
"""
|
||||||
SELECT g.id,
|
SELECT g.id,
|
||||||
g.telegram_chat_id AS TelegramChatId,
|
g.telegram_chat_id AS TelegramChatId,
|
||||||
|
g.external_group_id AS ExternalGroupId,
|
||||||
g.name,
|
g.name,
|
||||||
g.gm_telegram_id AS GmTelegramId,
|
g.platform AS Platform,
|
||||||
@OwnerRole AS ManagerRole
|
@OwnerRole AS ManagerRole
|
||||||
FROM game_groups g
|
FROM game_groups g
|
||||||
WHERE g.id = @GroupId
|
WHERE g.id = @GroupId
|
||||||
@@ -118,7 +139,7 @@ public sealed class SessionService(
|
|||||||
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsGroupManagerAsync(Guid groupId, long telegramId)
|
public async Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return await conn.ExecuteScalarAsync<bool>(
|
return await conn.ExecuteScalarAsync<bool>(
|
||||||
@@ -128,13 +149,14 @@ public sealed class SessionService(
|
|||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE gm.group_id = @GroupId
|
WHERE gm.group_id = @GroupId
|
||||||
AND p.telegram_id = @TelegramId
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId, TelegramId = telegramId });
|
new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> IsGroupOwnerAsync(Guid groupId, long telegramId)
|
public async Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return await conn.ExecuteScalarAsync<bool>(
|
return await conn.ExecuteScalarAsync<bool>(
|
||||||
@@ -144,11 +166,12 @@ public sealed class SessionService(
|
|||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
JOIN players p ON p.id = gm.player_id
|
JOIN players p ON p.id = gm.player_id
|
||||||
WHERE gm.group_id = @GroupId
|
WHERE gm.group_id = @GroupId
|
||||||
AND p.telegram_id = @TelegramId
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
AND gm.role = @OwnerRole
|
AND gm.role = @OwnerRole
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
new { GroupId = groupId, Platform = platform, ExternalUserId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
public async Task<List<WebGroupManager>> GetGroupManagersAsync(Guid groupId)
|
||||||
@@ -156,9 +179,11 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
return (await conn.QueryAsync<WebGroupManager>(
|
return (await conn.QueryAsync<WebGroupManager>(
|
||||||
"""
|
"""
|
||||||
SELECT p.telegram_id AS TelegramId,
|
SELECT COALESCE(p.telegram_id, 0) AS TelegramId,
|
||||||
|
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.telegram_username AS TelegramUsername,
|
||||||
|
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||||
gm.role AS Role,
|
gm.role AS Role,
|
||||||
gm.created_at AS AddedAt
|
gm.created_at AS AddedAt
|
||||||
FROM group_managers gm
|
FROM group_managers gm
|
||||||
@@ -179,7 +204,7 @@ public sealed class SessionService(
|
|||||||
SELECT
|
SELECT
|
||||||
p.id AS PlayerId,
|
p.id AS PlayerId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||||
COUNT(DISTINCT s.id) AS TotalSessions,
|
COUNT(DISTINCT s.id) AS TotalSessions,
|
||||||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
|
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Confirmed' THEN s.id END) AS ConfirmedCount,
|
||||||
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
|
COUNT(DISTINCT CASE WHEN sp.rsvp_status = 'Declined' THEN s.id END) AS DeclinedCount,
|
||||||
@@ -198,21 +223,21 @@ public sealed class SessionService(
|
|||||||
WHERE s.group_id = @GroupId
|
WHERE s.group_id = @GroupId
|
||||||
AND s.scheduled_at <= now()
|
AND s.scheduled_at <= now()
|
||||||
AND sp.is_gm = false
|
AND sp.is_gm = false
|
||||||
GROUP BY p.id, p.display_name, p.telegram_username
|
GROUP BY p.id, p.display_name, p.external_username, p.telegram_username
|
||||||
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
|
ORDER BY AttendanceRate DESC, ConfirmedCount DESC
|
||||||
""",
|
""",
|
||||||
new { GroupId = groupId })).ToList();
|
new { GroupId = groupId })).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
|
public async Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
INSERT INTO session_audit_log (session_id, actor_telegram_id, actor_name, change_type, old_value, new_value)
|
INSERT INTO session_audit_log (session_id, actor_external_user_id, actor_name, change_type, old_value, new_value)
|
||||||
VALUES (@SessionId, @ActorTelegramId, @ActorName, @ChangeType, @OldValue, @NewValue)
|
VALUES (@SessionId, @ActorExternalUserId, @ActorName, @ChangeType, @OldValue, @NewValue)
|
||||||
""",
|
""",
|
||||||
new { SessionId = sessionId, ActorTelegramId = actorTelegramId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
|
new { SessionId = sessionId, ActorExternalUserId = actorExternalUserId, ActorName = actorName, ChangeType = changeType, OldValue = oldValue, NewValue = newValue });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
|
public async Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId)
|
||||||
@@ -220,7 +245,7 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
var entries = await conn.QueryAsync<SessionAuditLogEntry>(
|
var entries = await conn.QueryAsync<SessionAuditLogEntry>(
|
||||||
"""
|
"""
|
||||||
SELECT id, session_id AS SessionId, actor_telegram_id AS ActorTelegramId, actor_name AS ActorName,
|
SELECT id, session_id AS SessionId, actor_external_user_id AS ActorExternalUserId, actor_name AS ActorName,
|
||||||
change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
|
change_type AS ChangeType, old_value AS OldValue, new_value AS NewValue, changed_at AS ChangedAt
|
||||||
FROM session_audit_log
|
FROM session_audit_log
|
||||||
WHERE session_id = @SessionId
|
WHERE session_id = @SessionId
|
||||||
@@ -230,32 +255,47 @@ public sealed class SessionService(
|
|||||||
return entries.ToList();
|
return entries.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl)
|
||||||
|
{
|
||||||
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"""
|
||||||
|
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||||
|
VALUES (@DisplayName, 'Discord', @DiscordId, @DisplayName)
|
||||||
|
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 { DisplayName = displayName, DiscordId = discordId });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task AddGroupCoGmAsync(
|
public async Task AddGroupCoGmAsync(
|
||||||
Guid groupId,
|
Guid groupId,
|
||||||
long ownerTelegramId,
|
string ownerPlatform, string ownerExternalUserId,
|
||||||
long coGmTelegramId,
|
string coGmPlatform, string coGmExternalUserId,
|
||||||
string displayName,
|
string displayName, string? externalUsername)
|
||||||
string? telegramUsername)
|
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
await using var transaction = await conn.BeginTransactionAsync();
|
await using var transaction = await conn.BeginTransactionAsync();
|
||||||
|
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
INSERT INTO players (telegram_id, display_name, telegram_username, platform, external_user_id, external_username)
|
INSERT INTO players (display_name, telegram_username, platform, external_user_id, external_username)
|
||||||
VALUES (@TelegramId, @DisplayName, @TelegramUsername, 'Telegram', @TelegramId::TEXT, @TelegramUsername)
|
VALUES (@DisplayName, @ExternalUsername, @Platform, @ExternalUserId, @ExternalUsername)
|
||||||
ON CONFLICT (telegram_id) DO UPDATE
|
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,
|
SET display_name = EXCLUDED.display_name,
|
||||||
telegram_username = EXCLUDED.telegram_username,
|
external_username = EXCLUDED.external_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
|
new
|
||||||
{
|
{
|
||||||
TelegramId = coGmTelegramId,
|
|
||||||
DisplayName = displayName,
|
DisplayName = displayName,
|
||||||
TelegramUsername = telegramUsername
|
ExternalUsername = externalUsername,
|
||||||
|
Platform = coGmPlatform,
|
||||||
|
ExternalUserId = coGmExternalUserId
|
||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
@@ -267,8 +307,8 @@ public sealed class SessionService(
|
|||||||
@CoGmRole,
|
@CoGmRole,
|
||||||
owner_player.id
|
owner_player.id
|
||||||
FROM players co_gm
|
FROM players co_gm
|
||||||
LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId
|
LEFT JOIN players owner_player ON owner_player.platform = @OwnerPlatform AND owner_player.external_user_id = @OwnerExternalUserId
|
||||||
WHERE co_gm.telegram_id = @CoGmTelegramId
|
WHERE co_gm.platform = @CoGmPlatform AND co_gm.external_user_id = @CoGmExternalUserId
|
||||||
ON CONFLICT (group_id, player_id) DO UPDATE
|
ON CONFLICT (group_id, player_id) DO UPDATE
|
||||||
SET role = CASE
|
SET role = CASE
|
||||||
WHEN group_managers.role = @OwnerRole THEN group_managers.role
|
WHEN group_managers.role = @OwnerRole THEN group_managers.role
|
||||||
@@ -279,8 +319,10 @@ public sealed class SessionService(
|
|||||||
new
|
new
|
||||||
{
|
{
|
||||||
GroupId = groupId,
|
GroupId = groupId,
|
||||||
OwnerTelegramId = ownerTelegramId,
|
OwnerPlatform = ownerPlatform,
|
||||||
CoGmTelegramId = coGmTelegramId,
|
OwnerExternalUserId = ownerExternalUserId,
|
||||||
|
CoGmPlatform = coGmPlatform,
|
||||||
|
CoGmExternalUserId = coGmExternalUserId,
|
||||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
OwnerRole = GroupManagerRoleExtensions.OwnerValue,
|
||||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||||
},
|
},
|
||||||
@@ -289,7 +331,7 @@ public sealed class SessionService(
|
|||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId)
|
public async Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
|
||||||
{
|
{
|
||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync(
|
||||||
@@ -298,13 +340,15 @@ public sealed class SessionService(
|
|||||||
USING players p
|
USING players p
|
||||||
WHERE gm.player_id = p.id
|
WHERE gm.player_id = p.id
|
||||||
AND gm.group_id = @GroupId
|
AND gm.group_id = @GroupId
|
||||||
AND p.telegram_id = @CoGmTelegramId
|
AND p.platform = @Platform
|
||||||
|
AND p.external_user_id = @ExternalUserId
|
||||||
AND gm.role = @CoGmRole
|
AND gm.role = @CoGmRole
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
GroupId = groupId,
|
GroupId = groupId,
|
||||||
CoGmTelegramId = coGmTelegramId,
|
Platform = coGmPlatform,
|
||||||
|
ExternalUserId = coGmExternalUserId,
|
||||||
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
CoGmRole = GroupManagerRoleExtensions.CoGmValue
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -426,7 +470,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (oldSession is null)
|
if (oldSession is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, 0);
|
throw new SessionAccessDeniedException(sessionId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var updatedRows = await conn.ExecuteAsync(
|
var updatedRows = await conn.ExecuteAsync(
|
||||||
@@ -454,7 +498,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (updatedRows == 0)
|
if (updatedRows == 0)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, 0);
|
throw new SessionAccessDeniedException(sessionId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
await conn.ExecuteAsync(
|
await conn.ExecuteAsync(
|
||||||
@@ -513,7 +557,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, 0);
|
throw new SessionAccessDeniedException(sessionId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var activeParticipants = await conn.ExecuteScalarAsync<int>(
|
var activeParticipants = await conn.ExecuteScalarAsync<int>(
|
||||||
@@ -597,9 +641,11 @@ public sealed class SessionService(
|
|||||||
return (await conn.QueryAsync<WebParticipant>(
|
return (await conn.QueryAsync<WebParticipant>(
|
||||||
"""
|
"""
|
||||||
SELECT sp.id AS Id,
|
SELECT sp.id AS Id,
|
||||||
p.telegram_id AS TelegramId,
|
COALESCE(p.telegram_id, 0) AS TelegramId,
|
||||||
|
COALESCE(p.external_user_id, p.telegram_id::TEXT) AS ExternalUserId,
|
||||||
p.display_name AS DisplayName,
|
p.display_name AS DisplayName,
|
||||||
p.telegram_username AS TelegramUsername,
|
p.telegram_username AS TelegramUsername,
|
||||||
|
COALESCE(p.external_username, p.telegram_username) AS ExternalUsername,
|
||||||
sp.rsvp_status AS RsvpStatus,
|
sp.rsvp_status AS RsvpStatus,
|
||||||
sp.registration_status AS RegistrationStatus,
|
sp.registration_status AS RegistrationStatus,
|
||||||
sp.is_gm AS IsGm,
|
sp.is_gm AS IsGm,
|
||||||
@@ -637,7 +683,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(sessionId, 0);
|
throw new SessionAccessDeniedException(sessionId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
var participant = await conn.QuerySingleOrDefaultAsync<WebParticipant>(
|
||||||
@@ -744,7 +790,7 @@ public sealed class SessionService(
|
|||||||
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, 0);
|
throw new SessionAccessDeniedException(batchId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var updatedRows = await conn.ExecuteAsync(
|
var updatedRows = await conn.ExecuteAsync(
|
||||||
@@ -767,7 +813,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (updatedRows == 0)
|
if (updatedRows == 0)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, 0);
|
throw new SessionAccessDeniedException(batchId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
@@ -786,7 +832,7 @@ public sealed class SessionService(
|
|||||||
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction);
|
||||||
if (batch is null)
|
if (batch is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, 0);
|
throw new SessionAccessDeniedException(batchId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var updatedRows = await conn.ExecuteAsync(
|
var updatedRows = await conn.ExecuteAsync(
|
||||||
@@ -807,7 +853,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (updatedRows == 0)
|
if (updatedRows == 0)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, 0);
|
throw new SessionAccessDeniedException(batchId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
@@ -844,7 +890,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (batchSessions.Count == 0)
|
if (batchSessions.Count == 0)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, 0);
|
throw new SessionAccessDeniedException(batchId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
|
var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule(
|
||||||
@@ -928,7 +974,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (sourceSessions.Count == 0)
|
if (sourceSessions.Count == 0)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(batchId, 0);
|
throw new SessionAccessDeniedException(batchId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var newBatchId = Guid.NewGuid();
|
var newBatchId = Guid.NewGuid();
|
||||||
@@ -1130,7 +1176,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (template is null)
|
if (template is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(templateId, 0);
|
throw new SessionAccessDeniedException(templateId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
var group = await conn.QuerySingleOrDefaultAsync<WebTemplateGroupDto>(
|
||||||
@@ -1140,7 +1186,7 @@ public sealed class SessionService(
|
|||||||
|
|
||||||
if (group is null)
|
if (group is null)
|
||||||
{
|
{
|
||||||
throw new SessionAccessDeniedException(groupId, 0);
|
throw new SessionAccessDeniedException(groupId, "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
|
var schedule = BatchSchedulePlanner.BuildRecurringSchedule(
|
||||||
|
|||||||
@@ -1619,3 +1619,78 @@ body.telegram-mini-app .session-card-mobile {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === Discord Login Button === */
|
||||||
|
.login-btn-discord {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background-color: #5865F2;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn-discord:hover {
|
||||||
|
background-color: #4752C4;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn-discord:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn-discord .login-btn-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider::before,
|
||||||
|
.login-divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider span {
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Platform Indicator in Nav === */
|
||||||
|
.nav-user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-platform {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-user-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public sealed class DiscordProjectStructureTests
|
|||||||
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
var prChecks = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "pr-checks.yml"));
|
||||||
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
var deploy = File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml"));
|
||||||
|
|
||||||
Assert.Contains("gmrelay-discord-bot:2.7.2", compose);
|
Assert.Contains("gmrelay-discord-bot:2.8.0", compose);
|
||||||
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
Assert.Contains("Discord__Token=${DISCORD_BOT_TOKEN:?Set DISCORD_BOT_TOKEN in .env}", compose);
|
||||||
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
Assert.Contains("src/GmRelay.DiscordBot/Dockerfile", deploy);
|
||||||
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
Assert.Contains("DISCORD_BOT_TOKEN", deploy);
|
||||||
@@ -75,13 +75,13 @@ public sealed class DiscordProjectStructureTests
|
|||||||
{
|
{
|
||||||
var repoRoot = GetRepoRoot();
|
var repoRoot = GetRepoRoot();
|
||||||
|
|
||||||
Assert.Contains("<Version>2.7.2</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
Assert.Contains("<Version>2.8.0</Version>", File.ReadAllText(Path.Combine(repoRoot, "Directory.Build.props")));
|
||||||
Assert.Contains("VERSION: 2.7.2", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
Assert.Contains("VERSION: 2.8.0", File.ReadAllText(Path.Combine(repoRoot, ".gitea", "workflows", "deploy.yml")));
|
||||||
Assert.Contains("gmrelay-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-web:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-web:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains("gmrelay-discord-bot:2.7.2", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
Assert.Contains("gmrelay-discord-bot:2.8.0", File.ReadAllText(Path.Combine(repoRoot, "compose.yaml")));
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"v2.7.2",
|
"v2.8.0",
|
||||||
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
File.ReadAllText(Path.Combine(repoRoot, "src", "GmRelay.Web", "Components", "Layout", "NavMenu.razor")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
using GmRelay.Web.Services;
|
using GmRelay.Web.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Web;
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
public sealed class AuthorizedSessionServiceTests
|
public sealed class AuthorizedSessionServiceTests
|
||||||
{
|
{
|
||||||
|
private static IHttpContextAccessor CreateAccessor(string externalUserId, string? name = null)
|
||||||
|
{
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, externalUserId),
|
||||||
|
new Claim("TelegramId", externalUserId),
|
||||||
|
new Claim("Platform", "Telegram")
|
||||||
|
};
|
||||||
|
if (name is not null)
|
||||||
|
claims.Add(new Claim(ClaimTypes.Name, name));
|
||||||
|
|
||||||
|
var identity = new ClaimsIdentity(claims, "Test");
|
||||||
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
var httpContext = new DefaultHttpContext { User = principal };
|
||||||
|
return new HttpContextAccessor { HttpContext = httpContext };
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
|
public async Task GetUpcomingSessionsForGmAsync_ReturnsSessions_WhenGroupBelongsToGm()
|
||||||
{
|
{
|
||||||
@@ -19,9 +38,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, gmId);
|
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
|
||||||
|
|
||||||
Assert.NotNull(sessions);
|
Assert.NotNull(sessions);
|
||||||
Assert.Single(sessions);
|
Assert.Single(sessions);
|
||||||
@@ -47,9 +67,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(coGmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, coGmId);
|
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
|
||||||
|
|
||||||
Assert.NotNull(sessions);
|
Assert.NotNull(sessions);
|
||||||
Assert.Single(sessions);
|
Assert.Single(sessions);
|
||||||
@@ -65,9 +86,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, 42, "Alpha", 2002L)
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var sessions = await service.GetUpcomingSessionsForGmAsync(groupId, 1001L);
|
var sessions = await service.GetUpcomingSessionsForCurrentUserAsync(groupId);
|
||||||
|
|
||||||
Assert.Null(sessions);
|
Assert.Null(sessions);
|
||||||
}
|
}
|
||||||
@@ -87,9 +109,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var session = await service.GetSessionForGmAsync(sessionId, gmId);
|
var session = await service.GetSessionForCurrentUserAsync(sessionId);
|
||||||
|
|
||||||
Assert.NotNull(session);
|
Assert.NotNull(session);
|
||||||
Assert.Equal(sessionId, session.Id);
|
Assert.Equal(sessionId, session.Id);
|
||||||
@@ -109,9 +132,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var session = await service.GetSessionForGmAsync(sessionId, 1001L);
|
var session = await service.GetSessionForCurrentUserAsync(sessionId);
|
||||||
|
|
||||||
Assert.Null(session);
|
Assert.Null(session);
|
||||||
}
|
}
|
||||||
@@ -130,9 +154,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.UpdateSessionForGmAsync(sessionId, 1001L, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
|
var action = () => service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", DateTime.UtcNow.AddDays(1), "https://example.test/b", 5);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
Assert.False(store.UpdateCalled);
|
Assert.False(store.UpdateCalled);
|
||||||
@@ -154,9 +179,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated", scheduledAt, "https://example.test/b", 5);
|
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated", scheduledAt, "https://example.test/b", 5);
|
||||||
|
|
||||||
Assert.True(store.UpdateCalled);
|
Assert.True(store.UpdateCalled);
|
||||||
Assert.Equal(groupId, store.LastUpdatedGroupId);
|
Assert.Equal(groupId, store.LastUpdatedGroupId);
|
||||||
@@ -183,9 +209,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", originalTime, "https://example.test/a", 4);
|
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", originalTime, "https://example.test/a", 4);
|
||||||
|
|
||||||
Assert.Single(store.LogEntries);
|
Assert.Single(store.LogEntries);
|
||||||
Assert.Equal("Title", store.LogEntries[0].ChangeType);
|
Assert.Equal("Title", store.LogEntries[0].ChangeType);
|
||||||
@@ -209,10 +236,11 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var newTime = originalTime.AddDays(1);
|
var newTime = originalTime.AddDays(1);
|
||||||
await service.UpdateSessionForGmAsync(sessionId, gmId, "Updated Title", newTime, "https://example.test/b", 5);
|
await service.UpdateSessionForCurrentUserAsync(sessionId, "Updated Title", newTime, "https://example.test/b", 5);
|
||||||
|
|
||||||
Assert.Equal(4, store.LogEntries.Count);
|
Assert.Equal(4, store.LogEntries.Count);
|
||||||
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
|
Assert.Contains(store.LogEntries, e => e.ChangeType == "Title");
|
||||||
@@ -237,9 +265,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", originalTime, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.UpdateSessionForGmAsync(sessionId, gmId, "Session A", originalTime, "https://example.test/a", 4);
|
await service.UpdateSessionForCurrentUserAsync(sessionId, "Session A", originalTime, "https://example.test/a", 4);
|
||||||
|
|
||||||
Assert.Empty(store.LogEntries);
|
Assert.Empty(store.LogEntries);
|
||||||
}
|
}
|
||||||
@@ -259,9 +288,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId);
|
var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
|
||||||
|
|
||||||
Assert.NotNull(history);
|
Assert.NotNull(history);
|
||||||
Assert.Empty(history);
|
Assert.Empty(history);
|
||||||
@@ -281,9 +311,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L);
|
var history = await service.GetSessionHistoryForCurrentUserAsync(sessionId);
|
||||||
|
|
||||||
Assert.Null(history);
|
Assert.Null(history);
|
||||||
}
|
}
|
||||||
@@ -303,9 +334,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 3, 1)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.PromoteWaitlistedPlayerForGmAsync(sessionId, gmId);
|
await service.PromoteWaitlistedPlayerForCurrentUserAsync(sessionId);
|
||||||
|
|
||||||
Assert.True(store.PromoteCalled);
|
Assert.True(store.PromoteCalled);
|
||||||
Assert.Equal(groupId, store.LastPromotedGroupId);
|
Assert.Equal(groupId, store.LastPromotedGroupId);
|
||||||
@@ -326,9 +358,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.UpdateBatchDetailsForGmAsync(batchId, 1001L, "Updated", "https://example.test/b");
|
var action = () => service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
Assert.False(store.UpdateBatchDetailsCalled);
|
Assert.False(store.UpdateBatchDetailsCalled);
|
||||||
@@ -349,9 +382,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.UpdateBatchDetailsForGmAsync(batchId, gmId, "Updated", "https://example.test/b");
|
await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
|
||||||
|
|
||||||
Assert.True(store.UpdateBatchDetailsCalled);
|
Assert.True(store.UpdateBatchDetailsCalled);
|
||||||
Assert.Equal(batchId, store.LastUpdatedBatchId);
|
Assert.Equal(batchId, store.LastUpdatedBatchId);
|
||||||
@@ -380,9 +414,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(coGmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.UpdateBatchDetailsForGmAsync(batchId, coGmId, "Updated", "https://example.test/b");
|
await service.UpdateBatchDetailsForCurrentUserAsync(batchId, "Updated", "https://example.test/b");
|
||||||
|
|
||||||
Assert.True(store.UpdateBatchDetailsCalled);
|
Assert.True(store.UpdateBatchDetailsCalled);
|
||||||
Assert.Equal(batchId, store.LastUpdatedBatchId);
|
Assert.Equal(batchId, store.LastUpdatedBatchId);
|
||||||
@@ -400,9 +435,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, 42, "Alpha", ownerId)
|
new(groupId, 42, "Alpha", ownerId)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(ownerId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.AddCoGmForOwnerAsync(groupId, ownerId, coGmId, "Assistant GM", "assistant");
|
await service.AddCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString(), "Assistant GM", "assistant");
|
||||||
|
|
||||||
Assert.True(store.AddCoGmCalled);
|
Assert.True(store.AddCoGmCalled);
|
||||||
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
|
Assert.Equal(groupId, store.LastAddedCoGmGroupId);
|
||||||
@@ -427,9 +463,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(coGmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.AddCoGmForOwnerAsync(groupId, coGmId, newCoGmId, "Second Assistant", null);
|
var action = () => service.AddCoGmForOwnerAsync(groupId, "Telegram", newCoGmId.ToString(), "Second Assistant", null);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
Assert.False(store.AddCoGmCalled);
|
Assert.False(store.AddCoGmCalled);
|
||||||
@@ -450,9 +487,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, coGmId, GroupManagerRole.CoGm)
|
new(groupId, coGmId, GroupManagerRole.CoGm)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(ownerId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.RemoveCoGmForOwnerAsync(groupId, ownerId, coGmId);
|
await service.RemoveCoGmForOwnerAsync(groupId, "Telegram", coGmId.ToString());
|
||||||
|
|
||||||
Assert.True(store.RemoveCoGmCalled);
|
Assert.True(store.RemoveCoGmCalled);
|
||||||
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
|
Assert.Equal(groupId, store.LastRemovedCoGmGroupId);
|
||||||
@@ -473,9 +511,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.UpdateBatchNotificationModeForGmAsync(batchId, 1001L, SessionNotificationMode.GroupOnly);
|
var action = () => service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
Assert.False(store.UpdateBatchNotificationModeCalled);
|
Assert.False(store.UpdateBatchNotificationModeCalled);
|
||||||
@@ -496,9 +535,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.UpdateBatchNotificationModeForGmAsync(batchId, gmId, SessionNotificationMode.GroupOnly);
|
await service.UpdateBatchNotificationModeForCurrentUserAsync(batchId, SessionNotificationMode.GroupOnly);
|
||||||
|
|
||||||
Assert.True(store.UpdateBatchNotificationModeCalled);
|
Assert.True(store.UpdateBatchNotificationModeCalled);
|
||||||
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
|
Assert.Equal(batchId, store.LastUpdatedNotificationBatchId);
|
||||||
@@ -521,9 +561,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.RescheduleBatchForGmAsync(batchId, gmId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
|
var action = () => service.RescheduleBatchForCurrentUserAsync(batchId, DateTime.UtcNow.AddDays(7), intervalDays: 0);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
|
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(action);
|
||||||
Assert.False(store.RescheduleBatchCalled);
|
Assert.False(store.RescheduleBatchCalled);
|
||||||
@@ -545,9 +586,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.RescheduleBatchForGmAsync(batchId, gmId, firstScheduledAt, intervalDays: 14);
|
await service.RescheduleBatchForCurrentUserAsync(batchId, firstScheduledAt, intervalDays: 14);
|
||||||
|
|
||||||
Assert.True(store.RescheduleBatchCalled);
|
Assert.True(store.RescheduleBatchCalled);
|
||||||
Assert.Equal(batchId, store.LastRescheduledBatchId);
|
Assert.Equal(batchId, store.LastRescheduledBatchId);
|
||||||
@@ -571,9 +613,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
new(Guid.NewGuid(), groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", batchId, 10, 42, 4, 1, 0)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.CloneBatchForGmAsync(batchId, gmId, BatchCloneInterval.NextWeek);
|
await service.CloneBatchForCurrentUserAsync(batchId, BatchCloneInterval.NextWeek);
|
||||||
|
|
||||||
Assert.True(store.CloneBatchCalled);
|
Assert.True(store.CloneBatchCalled);
|
||||||
Assert.Equal(batchId, store.LastClonedBatchId);
|
Assert.Equal(batchId, store.LastClonedBatchId);
|
||||||
@@ -591,11 +634,11 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, 42, "Alpha", gmId)
|
new(groupId, 42, "Alpha", gmId)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.CreateCampaignTemplateForGmAsync(
|
await service.CreateCampaignTemplateForCurrentUserAsync(
|
||||||
groupId,
|
groupId,
|
||||||
gmId,
|
|
||||||
new CreateCampaignTemplateRequest(
|
new CreateCampaignTemplateRequest(
|
||||||
" Weekly arc ",
|
" Weekly arc ",
|
||||||
" Kingmaker ",
|
" Kingmaker ",
|
||||||
@@ -626,11 +669,11 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, 42, "Alpha", gmId)
|
new(groupId, 42, "Alpha", gmId)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.CreateCampaignTemplateForGmAsync(
|
await service.CreateCampaignTemplateForCurrentUserAsync(
|
||||||
groupId,
|
groupId,
|
||||||
gmId,
|
|
||||||
new CreateCampaignTemplateRequest(
|
new CreateCampaignTemplateRequest(
|
||||||
"Open table",
|
"Open table",
|
||||||
"West Marches",
|
"West Marches",
|
||||||
@@ -653,11 +696,11 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(groupId, 42, "Alpha", 2002L)
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.CreateCampaignTemplateForGmAsync(
|
var action = () => service.CreateCampaignTemplateForCurrentUserAsync(
|
||||||
groupId,
|
groupId,
|
||||||
1001L,
|
|
||||||
new CreateCampaignTemplateRequest(
|
new CreateCampaignTemplateRequest(
|
||||||
"Weekly arc",
|
"Weekly arc",
|
||||||
"Kingmaker",
|
"Kingmaker",
|
||||||
@@ -687,9 +730,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
|
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor(gmId.ToString());
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
await service.CreateBatchFromCampaignTemplateForGmAsync(templateId, gmId, firstScheduledAt);
|
await service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, firstScheduledAt);
|
||||||
|
|
||||||
Assert.True(store.CreateBatchFromTemplateCalled);
|
Assert.True(store.CreateBatchFromTemplateCalled);
|
||||||
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
|
Assert.Equal(templateId, store.LastCreatedBatchTemplateId);
|
||||||
@@ -711,9 +755,10 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
[
|
[
|
||||||
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
|
new(templateId, groupId, "Weekly arc", "Kingmaker", "https://example.test/kingmaker", 6, 7, 5, SessionNotificationModeExtensions.GroupOnlyValue, DateTime.UtcNow, DateTime.UtcNow)
|
||||||
]);
|
]);
|
||||||
var service = new AuthorizedSessionService(store);
|
var accessor = CreateAccessor("1001");
|
||||||
|
var service = new AuthorizedSessionService(store, accessor);
|
||||||
|
|
||||||
var action = () => service.CreateBatchFromCampaignTemplateForGmAsync(templateId, 1001L, DateTime.UtcNow.AddDays(3));
|
var action = () => service.CreateBatchFromCampaignTemplateForCurrentUserAsync(templateId, DateTime.UtcNow.AddDays(3));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
await Assert.ThrowsAsync<SessionAccessDeniedException>(action);
|
||||||
Assert.False(store.CreateBatchFromTemplateCalled);
|
Assert.False(store.CreateBatchFromTemplateCalled);
|
||||||
@@ -1019,7 +1064,7 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
|
|
||||||
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
|
public Task LogSessionChangeAsync(Guid sessionId, long actorTelegramId, string actorName, string changeType, string? oldValue, string? newValue)
|
||||||
{
|
{
|
||||||
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
|
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorTelegramId.ToString(), actorName, changeType, oldValue, newValue, DateTime.UtcNow);
|
||||||
LogEntries.Add(entry);
|
LogEntries.Add(entry);
|
||||||
LastLogSessionId = sessionId;
|
LastLogSessionId = sessionId;
|
||||||
LastLogActorTelegramId = actorTelegramId;
|
LastLogActorTelegramId = actorTelegramId;
|
||||||
@@ -1033,12 +1078,62 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
|
public Task<List<SessionAuditLogEntry>> GetSessionHistoryAsync(Guid sessionId) =>
|
||||||
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
|
Task.FromResult(LogEntries.Where(e => e.SessionId == sessionId).OrderByDescending(e => e.ChangedAt).ToList());
|
||||||
|
|
||||||
|
public Task<List<WebGameGroup>> GetGroupsForUserAsync(string platform, string externalUserId) =>
|
||||||
|
Task.FromResult(groupsById.Values.Where(group => IsManager(group.Id, externalUserId)).ToList());
|
||||||
|
|
||||||
|
public Task<bool> IsGroupManagerAsync(Guid groupId, string platform, string externalUserId) =>
|
||||||
|
Task.FromResult(IsManager(groupId, externalUserId));
|
||||||
|
|
||||||
|
public Task<bool> IsGroupOwnerAsync(Guid groupId, string platform, string externalUserId) =>
|
||||||
|
Task.FromResult(IsOwner(groupId, externalUserId));
|
||||||
|
|
||||||
|
public Task AddGroupCoGmAsync(Guid groupId, string ownerPlatform, string ownerExternalUserId, string coGmPlatform, string coGmExternalUserId, string displayName, string? externalUsername)
|
||||||
|
{
|
||||||
|
AddCoGmCalled = true;
|
||||||
|
LastAddedCoGmGroupId = groupId;
|
||||||
|
LastAddedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
|
||||||
|
LastAddedCoGmDisplayName = displayName;
|
||||||
|
LastAddedCoGmUsername = externalUsername;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task RemoveGroupCoGmAsync(Guid groupId, string coGmPlatform, string coGmExternalUserId)
|
||||||
|
{
|
||||||
|
RemoveCoGmCalled = true;
|
||||||
|
LastRemovedCoGmGroupId = groupId;
|
||||||
|
LastRemovedCoGmTelegramId = long.TryParse(coGmExternalUserId, out var id) ? id : null;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task LogSessionChangeAsync(Guid sessionId, string actorExternalUserId, string actorName, string changeType, string? oldValue, string? newValue)
|
||||||
|
{
|
||||||
|
var entry = new SessionAuditLogEntry(Guid.NewGuid(), sessionId, actorExternalUserId, actorName, changeType, oldValue, newValue, DateTime.UtcNow);
|
||||||
|
LogEntries.Add(entry);
|
||||||
|
LastLogSessionId = sessionId;
|
||||||
|
LastLogActorTelegramId = long.TryParse(actorExternalUserId, out var id) ? (long?)id : null;
|
||||||
|
LastLogActorName = actorName;
|
||||||
|
LastLogChangeType = changeType;
|
||||||
|
LastLogOldValue = oldValue;
|
||||||
|
LastLogNewValue = newValue;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task UpsertDiscordUserAsync(string discordId, string displayName, string? avatarUrl) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
private bool IsManager(Guid groupId, long telegramId) =>
|
private bool IsManager(Guid groupId, long telegramId) =>
|
||||||
IsOwner(groupId, telegramId) ||
|
IsOwner(groupId, telegramId) ||
|
||||||
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId == telegramId);
|
||||||
|
|
||||||
private bool IsOwner(Guid groupId, long telegramId) =>
|
private bool IsOwner(Guid groupId, long telegramId) =>
|
||||||
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
|
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId == telegramId;
|
||||||
|
|
||||||
|
private bool IsManager(Guid groupId, string externalUserId) =>
|
||||||
|
IsOwner(groupId, externalUserId) ||
|
||||||
|
managers.Any(manager => manager.GroupId == groupId && manager.TelegramId.ToString() == externalUserId);
|
||||||
|
|
||||||
|
private bool IsOwner(Guid groupId, string externalUserId) =>
|
||||||
|
groupsById.TryGetValue(groupId, out var group) && group.GmTelegramId.ToString() == externalUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
|
private sealed record FakeGroupManager(Guid GroupId, long TelegramId, GroupManagerRole Role);
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using GmRelay.Web;
|
||||||
|
using GmRelay.Web.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public class DiscordAuthServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorizeUrl_GeneratesCorrectUrl()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Discord:ClientId"] = "12345",
|
||||||
|
["Discord:ClientSecret"] = "secret",
|
||||||
|
["Discord:RedirectUri"] = "https://example.com/callback"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var service = new DiscordAuthService(new TestHttpClientFactory(), config);
|
||||||
|
var url = service.BuildAuthorizeUrl("state123");
|
||||||
|
|
||||||
|
Assert.Contains("client_id=12345", url);
|
||||||
|
Assert.Contains("response_type=code", url);
|
||||||
|
Assert.Contains("state=state123", url);
|
||||||
|
Assert.Contains("https%3A%2F%2Fexample.com%2Fcallback", url);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildAuthorizeUrl_WithMissingConfig_ThrowsInvalidOperationException()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder().Build();
|
||||||
|
var service = new DiscordAuthService(new TestHttpClientFactory(), config);
|
||||||
|
|
||||||
|
Assert.Throws<InvalidOperationException>(() => service.BuildAuthorizeUrl("state"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExchangeCodeAsync_WithValidCode_ReturnsUser()
|
||||||
|
{
|
||||||
|
var handler = new TestHttpMessageHandler((request) =>
|
||||||
|
{
|
||||||
|
if (request.RequestUri?.AbsolutePath == "/api/oauth2/token")
|
||||||
|
{
|
||||||
|
var response = new DiscordTokenResponse("access_123", "Bearer", 604800, "identify");
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(response))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.RequestUri?.AbsolutePath == "/api/users/@me")
|
||||||
|
{
|
||||||
|
var user = new DiscordUser("98765", "TestUser", "0", "avatar123", null);
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(JsonSerializer.Serialize(user))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||||
|
});
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Discord:ClientId"] = "client",
|
||||||
|
["Discord:ClientSecret"] = "secret",
|
||||||
|
["Discord:RedirectUri"] = "https://example.com/callback"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var factory = new TestHttpClientFactory(handler);
|
||||||
|
var service = new DiscordAuthService(factory, config);
|
||||||
|
|
||||||
|
var result = await service.ExchangeCodeAsync("valid_code");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("98765", result.Id);
|
||||||
|
Assert.Equal("TestUser", result.Username);
|
||||||
|
Assert.Equal("https://cdn.discordapp.com/avatars/98765/avatar123.png", result.AvatarUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExchangeCodeAsync_WithInvalidCode_ReturnsNull()
|
||||||
|
{
|
||||||
|
var handler = new TestHttpMessageHandler((request) =>
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.BadRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Discord:ClientId"] = "client",
|
||||||
|
["Discord:ClientSecret"] = "secret",
|
||||||
|
["Discord:RedirectUri"] = "https://example.com/callback"
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var factory = new TestHttpClientFactory(handler);
|
||||||
|
var service = new DiscordAuthService(factory, config);
|
||||||
|
|
||||||
|
var result = await service.ExchangeCodeAsync("invalid_code");
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly HttpMessageHandler? _handler;
|
||||||
|
|
||||||
|
public TestHttpClientFactory(HttpMessageHandler? handler = null)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient CreateClient(string name) =>
|
||||||
|
_handler is not null ? new HttpClient(_handler) : new HttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||||
|
|
||||||
|
public TestHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_handler(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using GmRelay.Web.Services;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public class PlatformIdentityTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void TryGetPlatformIdentity_TelegramUser_ReturnsTelegramPlatform()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "12345"),
|
||||||
|
new Claim("TelegramId", "12345")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal("Telegram", platform);
|
||||||
|
Assert.Equal("12345", externalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetPlatformIdentity_TelegramUserWithPlatformClaim_ReturnsTelegramPlatform()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "12345"),
|
||||||
|
new Claim("Platform", "Telegram")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal("Telegram", platform);
|
||||||
|
Assert.Equal("12345", externalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetPlatformIdentity_DiscordUser_ReturnsDiscordPlatform()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "98765"),
|
||||||
|
new Claim("DiscordId", "98765"),
|
||||||
|
new Claim("Platform", "Discord")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.TryGetPlatformIdentity(out var platform, out var externalUserId);
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal("Discord", platform);
|
||||||
|
Assert.Equal("98765", externalUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetPlatformIdentity_UnknownUser_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.Name, "Anonymous")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.TryGetPlatformIdentity(out _, out _);
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetDiscordId_DiscordUser_ReturnsTrue()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim("DiscordId", "123")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.TryGetDiscordId(out var discordId);
|
||||||
|
Assert.True(result);
|
||||||
|
Assert.Equal("123", discordId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TryGetDiscordId_TelegramUser_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim("TelegramId", "123")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.TryGetDiscordId(out _);
|
||||||
|
Assert.False(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAvatarUrl_DiscordUserWithAvatar_ReturnsUrl()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim("AvatarUrl", "https://cdn.discordapp.com/avatars/1/abc.png")
|
||||||
|
}));
|
||||||
|
|
||||||
|
var result = user.GetAvatarUrl();
|
||||||
|
Assert.Equal("https://cdn.discordapp.com/avatars/1/abc.png", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetAvatarUrl_UserWithoutAvatar_ReturnsNull()
|
||||||
|
{
|
||||||
|
var user = new ClaimsPrincipal(new ClaimsIdentity(Array.Empty<Claim>()));
|
||||||
|
|
||||||
|
var result = user.GetAvatarUrl();
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user