feat: add campaign templates and recurring schedules
Deploy Telegram Bot / build-and-push (push) Successful in 3m49s
Deploy Telegram Bot / deploy (push) Successful in 10s

This commit is contained in:
2026-04-28 10:01:18 +03:00
parent a1ec688ec8
commit 0218890a7a
17 changed files with 1075 additions and 14 deletions
@@ -85,6 +85,83 @@
</div>
}
@if (campaignTemplates is not null)
{
<div class="glass-card campaign-template-panel animate-slide-up">
<div class="batch-bulk-header">
<div>
<h3>Шаблоны кампаний</h3>
<p>@campaignTemplateModels.Count сохранённых</p>
</div>
<span class="status-badge status-info">Template</span>
</div>
<EditForm Model="@campaignTemplateModel" OnValidSubmit="CreateCampaignTemplate">
<div class="campaign-template-fields">
<div class="gm-form-group">
<label class="gm-form-label">Название шаблона</label>
<InputText @bind-Value="campaignTemplateModel.Name" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Название кампании</label>
<InputText @bind-Value="campaignTemplateModel.Title" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Ссылка</label>
<InputText @bind-Value="campaignTemplateModel.JoinLink" class="gm-form-control" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Игр</label>
<InputNumber @bind-Value="campaignTemplateModel.SessionCount" class="gm-form-control" min="1" max="52" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Интервал, дней</label>
<InputNumber @bind-Value="campaignTemplateModel.IntervalDays" class="gm-form-control" min="1" max="365" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Мест</label>
<InputNumber @bind-Value="campaignTemplateModel.MaxPlayers" class="gm-form-control" min="1" />
</div>
<div class="gm-form-group">
<label class="gm-form-label">Уведомления</label>
<select @bind="campaignTemplateModel.NotificationMode" class="gm-form-control">
<option value="@SessionNotificationModeExtensions.GroupAndDirectValue">В группе и в личку</option>
<option value="@SessionNotificationModeExtensions.GroupOnlyValue">Только в группе</option>
</select>
</div>
</div>
<button type="submit" class="btn-gm btn-gm-primary" disabled="@isCreatingTemplate">
@(isCreatingTemplate ? "⏳ Сохраняем..." : "💾 Сохранить шаблон")
</button>
</EditForm>
@if (campaignTemplateModels.Count > 0)
{
<div class="campaign-template-list">
@foreach (var template in campaignTemplateModels)
{
<div class="campaign-template-row">
<div class="campaign-template-info">
<h3>@template.Name</h3>
<p>@FormatTemplateSummary(template)</p>
</div>
<div class="campaign-template-actions">
<input type="datetime-local" @bind="template.FirstScheduledAtLocal" class="gm-form-control" />
<button type="button" class="btn-gm btn-gm-success" disabled="@IsTemplateBusy(template)" @onclick="() => CreateBatchFromTemplate(template)">
@(processingTemplateId == template.Id ? "⏳ Создаём..." : "📅 Создать batch")
</button>
<button type="button" class="btn-gm btn-gm-danger" disabled="@IsTemplateBusy(template)" @onclick="() => DeleteCampaignTemplate(template)">
@(deletingTemplateId == template.Id ? "⏳ Удаляем..." : "Удалить")
</button>
</div>
</div>
}
</div>
}
</div>
}
@if (sessions == null)
{
<div class="glass-card" style="padding: 2rem;">
@@ -263,16 +340,22 @@
@code {
[Parameter] public Guid GroupId { get; set; }
private List<WebSession>? sessions;
private List<WebCampaignTemplate>? campaignTemplates;
private WebGroupManagement? groupManagement;
private List<BatchBulkEditModel> batchModels = [];
private List<CampaignTemplateUsageModel> campaignTemplateModels = [];
private Guid? promotingSessionId;
private Guid? processingBatchId;
private Guid? processingTemplateId;
private Guid? deletingTemplateId;
private long? removingCoGmId;
private bool isAddingCoGm;
private bool isCreatingTemplate;
private long telegramId;
private string? errorMessage;
private string? successMessage;
private CoGmEditModel coGmModel = new();
private CampaignTemplateEditModel campaignTemplateModel = new();
protected override async Task OnInitializedAsync()
{
@@ -302,7 +385,15 @@
return;
}
campaignTemplates = await SessionService.GetCampaignTemplatesForGmAsync(GroupId, telegramId);
if (campaignTemplates is null)
{
Navigation.NavigateTo("/access-denied");
return;
}
RebuildBatchModels();
RebuildCampaignTemplateModels();
}
private async Task AddCoGm()
@@ -497,6 +588,110 @@
}
}
private async Task CreateCampaignTemplate()
{
errorMessage = null;
successMessage = null;
if (!ValidateCampaignTemplate(campaignTemplateModel))
{
errorMessage = "Шаблон должен иметь название, ссылку, 1-52 игр, шаг 1-365 дней и положительный лимит мест, если он указан.";
return;
}
isCreatingTemplate = true;
try
{
await SessionService.CreateCampaignTemplateForGmAsync(
GroupId,
telegramId,
new CreateCampaignTemplateRequest(
campaignTemplateModel.Name,
campaignTemplateModel.Title,
campaignTemplateModel.JoinLink,
campaignTemplateModel.SessionCount,
campaignTemplateModel.IntervalDays,
campaignTemplateModel.MaxPlayers,
SessionNotificationModeExtensions.FromDatabaseValue(campaignTemplateModel.NotificationMode)));
campaignTemplateModel = new();
successMessage = "Шаблон кампании сохранён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось сохранить шаблон: " + ex.Message;
}
finally
{
isCreatingTemplate = false;
}
}
private async Task CreateBatchFromTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
processingTemplateId = template.Id;
try
{
var utcTime = new DateTimeOffset(template.FirstScheduledAtLocal, TimeSpan.FromHours(3)).ToUniversalTime().UtcDateTime;
if (utcTime <= DateTime.UtcNow)
{
errorMessage = "Первая дата batch должна быть в будущем.";
return;
}
var createdBatch = await SessionService.CreateBatchFromCampaignTemplateForGmAsync(template.Id, telegramId, utcTime);
successMessage = $"Batch создан из шаблона: {createdBatch.SessionCount} игр.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось создать batch из шаблона: " + ex.Message;
}
finally
{
processingTemplateId = null;
}
}
private async Task DeleteCampaignTemplate(CampaignTemplateUsageModel template)
{
errorMessage = null;
successMessage = null;
deletingTemplateId = template.Id;
try
{
await SessionService.DeleteCampaignTemplateForGmAsync(template.Id, telegramId);
successMessage = "Шаблон кампании удалён.";
await LoadSessions();
}
catch (SessionAccessDeniedException)
{
Navigation.NavigateTo("/access-denied");
}
catch (Exception ex)
{
errorMessage = "Не удалось удалить шаблон: " + ex.Message;
}
finally
{
deletingTemplateId = null;
}
}
private void RebuildBatchModels()
{
batchModels = sessions?
@@ -523,6 +718,27 @@
.ToList() ?? [];
}
private void RebuildCampaignTemplateModels()
{
var defaultStart = DateTime.UtcNow.AddDays(7).ToMoscow();
campaignTemplateModels = campaignTemplates?
.OrderByDescending(template => template.UpdatedAt)
.ThenBy(template => template.Name)
.Select(template => new CampaignTemplateUsageModel
{
Id = template.Id,
Name = template.Name,
Title = template.Title,
JoinLink = template.JoinLink,
SessionCount = template.SessionCount,
IntervalDays = template.IntervalDays,
MaxPlayers = template.MaxPlayers,
NotificationMode = template.NotificationMode,
FirstScheduledAtLocal = defaultStart
})
.ToList() ?? [];
}
private static bool ValidateBatchDetails(BatchBulkEditModel batch)
{
batch.Title = batch.Title.Trim();
@@ -530,8 +746,29 @@
return batch.Title.Length > 0 && batch.JoinLink.Length > 0;
}
private static bool ValidateCampaignTemplate(CampaignTemplateEditModel template)
{
template.Name = template.Name.Trim();
template.Title = template.Title.Trim();
template.JoinLink = template.JoinLink.Trim();
if (template.MaxPlayers.HasValue && template.MaxPlayers.Value <= 0)
{
return false;
}
return template.Name.Length > 0 &&
template.Title.Length > 0 &&
template.JoinLink.Length > 0 &&
template.SessionCount is >= 1 and <= 52 &&
template.IntervalDays is >= 1 and <= 365;
}
private bool IsBatchBusy(BatchBulkEditModel batch) => processingBatchId == batch.BatchId;
private bool IsTemplateBusy(CampaignTemplateUsageModel template) =>
processingTemplateId == template.Id || deletingTemplateId == template.Id;
private string CurrentUserRole =>
groupManagement?.Managers.FirstOrDefault(manager => manager.TelegramId == telegramId)?.Role
?? GroupManagerRoleExtensions.CoGmValue;
@@ -577,6 +814,22 @@
private static string FormatBatchSummary(BatchBulkEditModel batch) =>
$"{batch.SessionCount} игр · {FormatLocalMoscow(batch.FirstScheduledAtLocal)} — {FormatLocalMoscow(batch.LastScheduledAtLocal)}";
private static string FormatTemplateSummary(CampaignTemplateUsageModel template)
{
var seats = template.MaxPlayers.HasValue
? $"{template.MaxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)} мест"
: "без лимита";
return $"{template.Title} · {template.SessionCount} игр · каждые {template.IntervalDays} дн. · {seats} · {FormatNotificationMode(template.NotificationMode)}";
}
private static string FormatNotificationMode(string notificationMode) =>
SessionNotificationModeExtensions.FromDatabaseValue(notificationMode) switch
{
SessionNotificationMode.GroupOnly => "только группа",
_ => "группа и личка"
};
private static string FormatLocalMoscow(DateTime localMoscow) =>
localMoscow.ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"));
@@ -611,6 +864,30 @@
public string CloneInterval { get; set; } = "week";
}
private sealed class CampaignTemplateEditModel
{
public string Name { get; set; } = "";
public string Title { get; set; } = "";
public string JoinLink { get; set; } = "";
public int SessionCount { get; set; } = 6;
public int IntervalDays { get; set; } = 7;
public int? MaxPlayers { get; set; }
public string NotificationMode { get; set; } = SessionNotificationModeExtensions.GroupAndDirectValue;
}
private sealed class CampaignTemplateUsageModel
{
public Guid Id { get; init; }
public string Name { get; init; } = "";
public string Title { get; init; } = "";
public string JoinLink { get; init; } = "";
public int SessionCount { get; init; }
public int IntervalDays { get; init; }
public int? MaxPlayers { get; init; }
public string NotificationMode { get; init; } = SessionNotificationModeExtensions.GroupAndDirectValue;
public DateTime FirstScheduledAtLocal { get; set; } = DateTime.Now;
}
private sealed class CoGmEditModel
{
public long? TelegramId { get; set; }