refactor: unify session status model
Deploy Telegram Bot / build-and-push (push) Successful in 4m47s
Deploy Telegram Bot / deploy (push) Successful in 19s

Fixes #5
This commit is contained in:
2026-04-24 10:26:45 +03:00
parent bb8cbb7a40
commit b80002aa36
16 changed files with 123 additions and 34 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ on:
- main - main
env: env:
VERSION: 1.1.3 VERSION: 1.1.4
jobs: jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>1.1.3</Version> <Version>1.1.4</Version>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion> <LangVersion>preview</LangVersion>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+2 -2
View File
@@ -18,7 +18,7 @@ services:
retries: 10 retries: 10
bot: bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.3 image: git.codeanddice.ru/toutsu/gmrelay-bot:1.1.4
container_name: gmrelay_bot container_name: gmrelay_bot
restart: always restart: always
network_mode: host network_mode: host
@@ -30,7 +30,7 @@ services:
- "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}" - "Telegram__BotToken=${TELEGRAM_BOT_TOKEN}"
web: web:
image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.3 image: git.codeanddice.ru/toutsu/gmrelay-web:1.1.4
container_name: gmrelay_web container_name: gmrelay_web
restart: always restart: always
network_mode: host network_mode: host
@@ -48,7 +48,10 @@ public sealed class CancelSessionHandler(
} }
// 2. Отменяем сессию // 2. Отменяем сессию
await connection.ExecuteAsync("UPDATE sessions SET status = 'Cancelled' WHERE id = @Id", new { Id = command.SessionId }, transaction); await connection.ExecuteAsync(
"UPDATE sessions SET status = @Status WHERE id = @Id",
new { Id = command.SessionId, Status = SessionStatus.Cancelled },
transaction);
// 3. Загружаем весь батч для перерисовки // 3. Загружаем весь батч для перерисовки
var batchSessions = await connection.QueryAsync<SessionBatchDto>( var batchSessions = await connection.QueryAsync<SessionBatchDto>(
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering; using GmRelay.Shared.Rendering;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -93,7 +94,7 @@ public sealed class CreateSessionHandler(
var sessionId = await connection.ExecuteScalarAsync<Guid>( var sessionId = await connection.ExecuteScalarAsync<Guid>(
""" """
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id) INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id)
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, 'Planned', @ThreadId) VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @ThreadId)
RETURNING id; RETURNING id;
""", """,
new new
@@ -103,11 +104,12 @@ public sealed class CreateSessionHandler(
Title = title, Title = title,
Link = link, Link = link,
ScheduledAt = scheduledAt, ScheduledAt = scheduledAt,
ThreadId = messageThreadId ThreadId = messageThreadId,
Status = SessionStatus.Planned
}, },
transaction); transaction);
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, "Planned")); sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned));
} }
await transaction.CommitAsync(cancellationToken); await transaction.CommitAsync(cancellationToken);
@@ -1,5 +1,6 @@
using System.Text; using System.Text;
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
using Telegram.Bot.Types; using Telegram.Bot.Types;
@@ -21,10 +22,10 @@ public sealed class ExportCalendarHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE g.telegram_chat_id = @ChatId WHERE g.telegram_chat_id = @ChatId
AND s.status = 'Planned' AND s.status = @Planned
AND s.scheduled_at > NOW() AND s.scheduled_at > NOW()
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Planned = SessionStatus.Planned });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -80,10 +80,10 @@ public sealed class DeleteSessionHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = command.ChatId }); new { ChatId = command.ChatId, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -23,10 +23,10 @@ public sealed class ListSessionsHandler(
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
LEFT JOIN session_participants sp ON s.id = sp.session_id LEFT JOIN session_participants sp ON s.id = sp.session_id
WHERE g.telegram_chat_id = @ChatId AND s.status != 'Cancelled' AND s.scheduled_at > NOW() WHERE g.telegram_chat_id = @ChatId AND s.status != @Cancelled AND s.scheduled_at > NOW()
GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id GROUP BY s.id, s.title, s.scheduled_at, s.status, g.gm_telegram_id
ORDER BY s.scheduled_at ASC", ORDER BY s.scheduled_at ASC",
new { ChatId = message.Chat.Id }); new { ChatId = message.Chat.Id, Cancelled = SessionStatus.Cancelled });
var sessionsList = sessions.ToList(); var sessionsList = sessions.ToList();
@@ -154,10 +154,10 @@ public sealed class HandleRescheduleTimeInputHandler(
await connection.ExecuteAsync( await connection.ExecuteAsync(
""" """
UPDATE sessions SET scheduled_at = @NewTime, status = 'Planned', updated_at = now() UPDATE sessions SET scheduled_at = @NewTime, status = @Status, updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -165,13 +165,13 @@ public sealed class HandleRescheduleVoteHandler(
""" """
UPDATE sessions UPDATE sessions
SET scheduled_at = @NewTime, SET scheduled_at = @NewTime,
status = 'Planned', status = @Status,
confirmation_message_id = NULL, confirmation_message_id = NULL,
link_message_id = NULL, link_message_id = NULL,
updated_at = now() updated_at = now()
WHERE id = @SessionId WHERE id = @SessionId
""", """,
new { NewTime = newTime, proposal.SessionId }, new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
transaction); transaction);
await connection.ExecuteAsync( await connection.ExecuteAsync(
@@ -1,4 +1,5 @@
using Dapper; using Dapper;
using GmRelay.Shared.Domain;
using Npgsql; using Npgsql;
using Telegram.Bot; using Telegram.Bot;
@@ -39,9 +40,9 @@ public sealed class InitiateRescheduleHandler(
SELECT s.title AS Title, g.gm_telegram_id AS GmId SELECT s.title AS Title, g.gm_telegram_id AS GmId
FROM sessions s FROM sessions s
JOIN game_groups g ON s.group_id = g.id JOIN game_groups g ON s.group_id = g.id
WHERE s.id = @SessionId AND s.status != 'Cancelled' WHERE s.id = @SessionId AND s.status != @Cancelled
""", """,
new { command.SessionId }); new { command.SessionId, Cancelled = SessionStatus.Cancelled });
if (session is null) if (session is null)
{ {
@@ -1,3 +1,5 @@
using System.Collections.Frozen;
namespace GmRelay.Shared.Domain; namespace GmRelay.Shared.Domain;
public static class SessionStatus public static class SessionStatus
@@ -6,4 +8,13 @@ public static class SessionStatus
public const string ConfirmationSent = "ConfirmationSent"; public const string ConfirmationSent = "ConfirmationSent";
public const string Confirmed = "Confirmed"; public const string Confirmed = "Confirmed";
public const string Cancelled = "Cancelled"; public const string Cancelled = "Cancelled";
public static IReadOnlySet<string> All { get; } =
new[] { Planned, ConfirmationSent, Confirmed, Cancelled }
.ToFrozenSet(StringComparer.Ordinal);
public static bool IsKnown(string status) => All.Contains(status);
public static bool IsCancelled(string status) =>
string.Equals(status, Cancelled, StringComparison.Ordinal);
} }
@@ -36,14 +36,10 @@ public static class SessionBatchRenderer
messageText += " <i>Пока никто не записался</i>\n"; messageText += " <i>Пока никто не записался</i>\n";
} }
if (session.Status == "Cancelled") if (SessionStatus.IsCancelled(session.Status))
{ {
messageText += "❌ <i>Сессия отменена</i>\n\n"; messageText += "❌ <i>Сессия отменена</i>\n\n";
} }
else if (session.Status == "RecruitmentClosed")
{
messageText += "🔒 <i>Набор завершен</i>\n\n";
}
else else
{ {
messageText += "\n"; messageText += "\n";
@@ -134,15 +134,12 @@
SessionStatus.Confirmed => "status-success", SessionStatus.Confirmed => "status-success",
SessionStatus.Cancelled => "status-danger", SessionStatus.Cancelled => "status-danger",
SessionStatus.ConfirmationSent => "status-warning", SessionStatus.ConfirmationSent => "status-warning",
"Recruiting" => "status-info", SessionStatus.Planned => "status-info",
"RecruitmentClosed" => "status-info",
_ => "status-neutral" _ => "status-neutral"
}; };
private string TranslateStatus(string status) => status switch private string TranslateStatus(string status) => status switch
{ {
"Recruiting" => "Набор",
"RecruitmentClosed" => "Набор закрыт",
SessionStatus.Planned => "Запланировано", SessionStatus.Planned => "Запланировано",
SessionStatus.ConfirmationSent => "Ждём подтверждения", SessionStatus.ConfirmationSent => "Ждём подтверждения",
SessionStatus.Confirmed => "Подтверждено", SessionStatus.Confirmed => "Подтверждено",
@@ -0,0 +1,75 @@
using System.Reflection;
using GmRelay.Shared.Domain;
namespace GmRelay.Bot.Tests.Domain;
public sealed class SessionStatusTests
{
[Fact]
public void All_ShouldContainOnlyCanonicalSessionStatuses()
{
var allProperty = typeof(SessionStatus).GetProperty(
"All",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(allProperty);
var allStatusValues = Assert.IsAssignableFrom<IReadOnlySet<string>>(allProperty.GetValue(null));
var expectedStatusValues = new[]
{
SessionStatus.Planned,
SessionStatus.ConfirmationSent,
SessionStatus.Confirmed,
SessionStatus.Cancelled
}
.Order(StringComparer.Ordinal);
Assert.Equal(expectedStatusValues, allStatusValues.Order(StringComparer.Ordinal));
}
[Fact]
public void ProductionSources_ShouldNotReferenceLegacySessionStatuses()
{
var repositoryRoot = FindRepositoryRoot();
var productionFiles = Directory.EnumerateFiles(repositoryRoot, "*.*", SearchOption.AllDirectories)
.Where(path => IsProductionSource(path))
.ToList();
var legacyStatuses = new[] { "Recruit" + "ing", "Recruitment" + "Closed" };
var offenders = productionFiles
.SelectMany(path => legacyStatuses
.Where(status => File.ReadAllText(path).Contains(status, StringComparison.Ordinal))
.Select(status => $"{Path.GetRelativePath(repositoryRoot, path)} contains {status}"))
.ToList();
Assert.Empty(offenders);
}
private static bool IsProductionSource(string path)
{
var extension = Path.GetExtension(path);
var separator = Path.DirectorySeparatorChar;
return path.Contains($"{separator}src{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}bin{separator}", StringComparison.Ordinal)
&& !path.Contains($"{separator}obj{separator}", StringComparison.Ordinal)
&& extension is ".cs" or ".razor" or ".sql";
}
private static string FindRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "GM-Relay.slnx")))
{
return current.FullName;
}
current = current.Parent;
}
throw new InvalidOperationException("Could not find repository root.");
}
}
@@ -6,7 +6,7 @@ namespace GmRelay.Bot.Tests.Rendering;
public sealed class SessionBatchRendererTests public sealed class SessionBatchRendererTests
{ {
[Fact] [Fact]
public void Render_ShouldOrderSessionsAndSkipButtonsForClosedStatuses() public void Render_ShouldOrderSessionsAndSkipButtonsForCancelledSessions()
{ {
var firstSessionId = Guid.NewGuid(); var firstSessionId = Guid.NewGuid();
var secondSessionId = Guid.NewGuid(); var secondSessionId = Guid.NewGuid();
@@ -14,9 +14,9 @@ public sealed class SessionBatchRendererTests
var sessions = new[] var sessions = new[]
{ {
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), "Planned"), new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned),
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), "Cancelled"), new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled),
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), "RecruitmentClosed") new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned)
}; };
var participants = new[] var participants = new[]
{ {
@@ -37,9 +37,12 @@ public sealed class SessionBatchRendererTests
Assert.True(secondIndex < thirdIndex); Assert.True(secondIndex < thirdIndex);
Assert.Contains("@alice", text); Assert.Contains("@alice", text);
Assert.Contains("Bob", text); Assert.Contains("Bob", text);
Assert.Single(result.Markup.InlineKeyboard); Assert.Equal(2, result.Markup.InlineKeyboard.Count());
Assert.Collection( Assert.Collection(
buttons.Select(button => button.CallbackData), buttons.Select(button => button.CallbackData),
callbackData => Assert.Equal($"join_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{firstSessionId}", callbackData),
callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"join_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData), callbackData => Assert.Equal($"cancel_session:{secondSessionId}", callbackData),
callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData)); callbackData => Assert.Equal($"reschedule_session:{secondSessionId}", callbackData));