feat: add session capacity waitlist
This commit is contained in:
@@ -47,7 +47,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v1.1.0</div>
|
||||
<div class="nav-version">v1.2.0</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -51,6 +51,12 @@
|
||||
<InputText @bind-Value="model.JoinLink" class="gm-form-control" placeholder="Ссылка на Discord или VTT" />
|
||||
</div>
|
||||
|
||||
<div class="gm-form-group">
|
||||
<label class="gm-form-label">Лимит мест</label>
|
||||
<InputNumber @bind-Value="model.MaxPlayers" class="gm-form-control" min="1" placeholder="Без лимита" />
|
||||
<div class="gm-form-hint">Пустое значение означает запись без лимита. Если лимит заполнен, новые игроки попадут в лист ожидания.</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 0.75rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn-gm btn-gm-success" disabled="@isSubmitting">
|
||||
@(isSubmitting ? "⏳ Сохранение..." : "✅ Сохранить изменения")
|
||||
@@ -97,6 +103,7 @@
|
||||
model.Title = session.Title;
|
||||
model.ScheduledAtLocal = session.ScheduledAt.ToMoscow();
|
||||
model.JoinLink = session.JoinLink;
|
||||
model.MaxPlayers = session.MaxPlayers;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
@@ -115,7 +122,7 @@
|
||||
|
||||
var utcTime = new DateTimeOffset(model.ScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
|
||||
|
||||
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink);
|
||||
await SessionService.UpdateSessionForGmAsync(SessionId, telegramId, model.Title, utcTime, model.JoinLink, model.MaxPlayers);
|
||||
Navigation.NavigateTo($"/group/{session!.GroupId}");
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
@@ -139,5 +146,6 @@
|
||||
public string Title { get; set; } = "";
|
||||
public DateTime ScheduledAtLocal { get; set; } = DateTime.Now;
|
||||
public string JoinLink { get; set; } = "";
|
||||
public int? MaxPlayers { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<h2>📅 Предстоящие игры</h2>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="gm-alert gm-alert-danger" style="margin-bottom: 1rem;">
|
||||
⚠️ @errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (sessions == null)
|
||||
{
|
||||
<div class="glass-card" style="padding: 2rem;">
|
||||
@@ -48,6 +55,7 @@
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Время (МСК)</th>
|
||||
<th>Места</th>
|
||||
<th>Статус</th>
|
||||
<th>Ссылка</th>
|
||||
<th>Действие</th>
|
||||
@@ -59,6 +67,7 @@
|
||||
<tr>
|
||||
<td style="color: var(--text-primary); font-weight: 500;">@session.Title</td>
|
||||
<td>@session.ScheduledAt.FormatMoscow()</td>
|
||||
<td>@FormatSeats(session)</td>
|
||||
<td>
|
||||
<span class="status-badge @GetStatusClass(session.Status)">@TranslateStatus(session.Status)</span>
|
||||
</td>
|
||||
@@ -69,9 +78,17 @@
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@if (CanPromote(session))
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-success" style="font-size: 0.8125rem; padding: 0.375rem 0.75rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
|
||||
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -93,6 +110,10 @@
|
||||
<span>🕐 Время</span>
|
||||
<span style="color: var(--text-primary);">@session.ScheduledAt.FormatMoscow()</span>
|
||||
</div>
|
||||
<div class="session-card-row">
|
||||
<span>👥 Места</span>
|
||||
<span style="color: var(--text-primary);">@FormatSeats(session)</span>
|
||||
</div>
|
||||
<div class="session-card-row">
|
||||
<span>🔗 Ссылка</span>
|
||||
<a href="@session.JoinLink" target="_blank" rel="noopener noreferrer">Подключиться ↗</a>
|
||||
@@ -102,6 +123,12 @@
|
||||
<a href="/session/edit/@session.Id" class="btn-gm btn-gm-outline" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;">
|
||||
✏️ Изменить
|
||||
</a>
|
||||
@if (CanPromote(session))
|
||||
{
|
||||
<button type="button" class="btn-gm btn-gm-success" style="flex: 1; justify-content: center; font-size: 0.8125rem; padding: 0.5rem;" disabled="@(promotingSessionId == session.Id)" @onclick="() => PromoteWaitlisted(session.Id)">
|
||||
@(promotingSessionId == session.Id ? "⏳ Поднимаем..." : "⬆️ Из ожидания")
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -112,11 +139,14 @@
|
||||
@code {
|
||||
[Parameter] public Guid GroupId { get; set; }
|
||||
private List<WebSession>? sessions;
|
||||
private Guid? promotingSessionId;
|
||||
private long telegramId;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
if (!authState.User.TryGetTelegramId(out var telegramId))
|
||||
if (!authState.User.TryGetTelegramId(out telegramId))
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
return;
|
||||
@@ -129,6 +159,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PromoteWaitlisted(Guid sessionId)
|
||||
{
|
||||
errorMessage = null;
|
||||
promotingSessionId = sessionId;
|
||||
|
||||
try
|
||||
{
|
||||
await SessionService.PromoteWaitlistedPlayerForGmAsync(sessionId, telegramId);
|
||||
sessions = await SessionService.GetUpcomingSessionsForGmAsync(GroupId, telegramId);
|
||||
}
|
||||
catch (SessionAccessDeniedException)
|
||||
{
|
||||
Navigation.NavigateTo("/access-denied");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
promotingSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanPromote(WebSession session) =>
|
||||
session.WaitlistedPlayerCount > 0 &&
|
||||
(!session.MaxPlayers.HasValue || session.ActivePlayerCount < session.MaxPlayers.Value);
|
||||
|
||||
private static string FormatSeats(WebSession session)
|
||||
{
|
||||
var seats = session.MaxPlayers.HasValue
|
||||
? $"{session.ActivePlayerCount}/{session.MaxPlayers.Value}"
|
||||
: session.ActivePlayerCount.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
return session.WaitlistedPlayerCount > 0
|
||||
? $"{seats} · ожидание {session.WaitlistedPlayerCount}"
|
||||
: seats;
|
||||
}
|
||||
|
||||
private string GetStatusClass(string status) => status switch
|
||||
{
|
||||
SessionStatus.Confirmed => "status-success",
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
return await GroupBelongsToGmAsync(session.GroupId, gmId) ? session : null;
|
||||
}
|
||||
|
||||
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink)
|
||||
public async Task UpdateSessionForGmAsync(Guid sessionId, long gmId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||
{
|
||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||
if (session is null)
|
||||
@@ -34,7 +34,18 @@ public sealed class AuthorizedSessionService(ISessionStore sessionStore)
|
||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||
}
|
||||
|
||||
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink);
|
||||
await sessionStore.UpdateSessionAsync(sessionId, session.GroupId, title, scheduledAt, joinLink, maxPlayers);
|
||||
}
|
||||
|
||||
public async Task PromoteWaitlistedPlayerForGmAsync(Guid sessionId, long gmId)
|
||||
{
|
||||
var session = await GetSessionForGmAsync(sessionId, gmId);
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, gmId);
|
||||
}
|
||||
|
||||
await sessionStore.PromoteWaitlistedPlayerAsync(sessionId, session.GroupId);
|
||||
}
|
||||
|
||||
private async Task<bool> GroupBelongsToGmAsync(Guid groupId, long gmId)
|
||||
|
||||
@@ -6,5 +6,6 @@ public interface ISessionStore
|
||||
Task<WebGameGroup?> GetGroupAsync(Guid groupId);
|
||||
Task<List<WebSession>> GetUpcomingSessionsAsync(Guid groupId);
|
||||
Task<WebSession?> GetSessionAsync(Guid sessionId);
|
||||
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink);
|
||||
Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers);
|
||||
Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,21 @@ using Telegram.Bot;
|
||||
namespace GmRelay.Web.Services;
|
||||
|
||||
public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId);
|
||||
public sealed record WebSession(Guid Id, Guid GroupId, string Title, DateTime ScheduledAt, string Status, string JoinLink, Guid BatchId, int? BatchMessageId, long TelegramChatId);
|
||||
public sealed record WebSession(
|
||||
Guid Id,
|
||||
Guid GroupId,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
string JoinLink,
|
||||
Guid BatchId,
|
||||
int? BatchMessageId,
|
||||
long TelegramChatId,
|
||||
int? MaxPlayers,
|
||||
int ActivePlayerCount,
|
||||
int WaitlistedPlayerCount);
|
||||
|
||||
internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName);
|
||||
|
||||
public sealed class SessionService(
|
||||
NpgsqlDataSource dataSource,
|
||||
@@ -36,12 +50,34 @@ public sealed class SessionService(
|
||||
return (await conn.QueryAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours'
|
||||
ORDER BY s.scheduled_at",
|
||||
new { GroupId = groupId })).ToList();
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
})).ToList();
|
||||
}
|
||||
|
||||
public async Task<WebSession?> GetSessionAsync(Guid sessionId)
|
||||
@@ -50,14 +86,36 @@ public sealed class SessionService(
|
||||
return await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
COALESCE(active_counts.count, 0)::int AS ActivePlayerCount,
|
||||
COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
) active_counts ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) AS count
|
||||
FROM session_participants sp
|
||||
WHERE sp.session_id = s.id
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
) waitlist_counts ON true
|
||||
WHERE s.id = @SessionId",
|
||||
new { SessionId = sessionId });
|
||||
new
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
}
|
||||
|
||||
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink)
|
||||
public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
@@ -65,7 +123,10 @@ public sealed class SessionService(
|
||||
var oldSession = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @Id AND s.group_id = @GroupId",
|
||||
@@ -82,9 +143,18 @@ public sealed class SessionService(
|
||||
SET title = @Title,
|
||||
scheduled_at = @ScheduledAt,
|
||||
join_link = @JoinLink,
|
||||
max_players = @MaxPlayers,
|
||||
updated_at = now()
|
||||
WHERE id = @Id AND group_id = @GroupId",
|
||||
new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink },
|
||||
new
|
||||
{
|
||||
Id = sessionId,
|
||||
GroupId = groupId,
|
||||
Title = title,
|
||||
ScheduledAt = scheduledAt,
|
||||
JoinLink = joinLink,
|
||||
MaxPlayers = maxPlayers
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (updatedRows == 0)
|
||||
@@ -102,7 +172,9 @@ public sealed class SessionService(
|
||||
var timeChanged = oldSession.ScheduledAt != scheduledAt;
|
||||
var notification = $"🔄 <b>Мастер обновил игру!</b>\n\n" +
|
||||
$"📌 <b>{System.Net.WebUtility.HtmlEncode(title)}</b>\n" +
|
||||
$"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : "");
|
||||
$"📅 Время: <b>{scheduledAt.FormatMoscow()}</b> (МСК)" + (timeChanged ? " (изменено)" : "") +
|
||||
"\n" +
|
||||
$"👥 Мест: <b>{(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}</b>";
|
||||
|
||||
await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||
|
||||
@@ -112,6 +184,104 @@ public sealed class SessionService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId)
|
||||
{
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
await using var transaction = await conn.BeginTransactionAsync();
|
||||
|
||||
var session = await conn.QuerySingleOrDefaultAsync<WebSession>(
|
||||
@"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.telegram_chat_id AS TelegramChatId,
|
||||
s.max_players AS MaxPlayers,
|
||||
0 AS ActivePlayerCount,
|
||||
0 AS WaitlistedPlayerCount
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE s.id = @SessionId AND s.group_id = @GroupId
|
||||
FOR UPDATE",
|
||||
new { SessionId = sessionId, GroupId = groupId },
|
||||
transaction);
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
throw new SessionAccessDeniedException(sessionId, 0);
|
||||
}
|
||||
|
||||
var activeParticipants = await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId
|
||||
AND is_gm = false
|
||||
AND registration_status = @Active
|
||||
""",
|
||||
new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction);
|
||||
|
||||
var waitlistedParticipants = await conn.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM session_participants
|
||||
WHERE session_id = @SessionId
|
||||
AND is_gm = false
|
||||
AND registration_status = @Waitlisted
|
||||
""",
|
||||
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||
transaction);
|
||||
|
||||
if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants))
|
||||
{
|
||||
throw new InvalidOperationException(waitlistedParticipants == 0
|
||||
? "Лист ожидания пуст."
|
||||
: "Нет свободных мест для повышения игрока.");
|
||||
}
|
||||
|
||||
var promoted = await conn.QuerySingleAsync<WebPromotedParticipantDto>(
|
||||
"""
|
||||
SELECT sp.id AS ParticipantRowId,
|
||||
p.display_name AS DisplayName
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Waitlisted
|
||||
ORDER BY sp.created_at ASC, sp.id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE OF sp
|
||||
""",
|
||||
new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted },
|
||||
transaction);
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"""
|
||||
UPDATE session_participants
|
||||
SET registration_status = @Active,
|
||||
rsvp_status = @Pending,
|
||||
responded_at = NULL
|
||||
WHERE id = @ParticipantRowId
|
||||
""",
|
||||
new
|
||||
{
|
||||
promoted.ParticipantRowId,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Pending = RsvpStatus.Pending
|
||||
},
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
|
||||
await bot.SendMessage(
|
||||
session.TelegramChatId,
|
||||
$"⬆️ <b>{System.Net.WebUtility.HtmlEncode(promoted.DisplayName)}</b> переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».",
|
||||
parseMode: Telegram.Bot.Types.Enums.ParseMode.Html);
|
||||
|
||||
if (session.BatchMessageId.HasValue)
|
||||
{
|
||||
await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title)
|
||||
{
|
||||
try
|
||||
@@ -119,16 +289,19 @@ public sealed class SessionService(
|
||||
await using var conn = await dataSource.OpenConnectionAsync();
|
||||
|
||||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||
new { BatchId = batchId })).ToList();
|
||||
|
||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||
@"SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername
|
||||
@"SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.telegram_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.responded_at ASC, p.created_at ASC",
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC",
|
||||
new { BatchId = batchId })).ToList();
|
||||
|
||||
var renderResult = SessionBatchRenderer.Render(title, sessions, participants);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ============================================
|
||||
GM-Relay Design System v1.1.0
|
||||
GM-Relay Design System v1.2.0
|
||||
Dark RPG Dashboard Theme
|
||||
============================================ */
|
||||
|
||||
@@ -821,4 +821,4 @@ input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||
.glass-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user