Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
fa7a680
fix(infrastructure): replace unsupported Include expression with Wher…
SubochArtem Apr 6, 2026
f651f9c
fix(infrastructure): replace unsupported Include expression with Wher…
SubochArtem Apr 6, 2026
3792ff5
refactor(domain): update formating in Error
SubochArtem Apr 6, 2026
bf80669
refactor(domain): update formating in Error
SubochArtem Apr 6, 2026
4e14232
fix(application): use ICommand instead of IRequest<Result<Unit>> in C…
SubochArtem Apr 8, 2026
710490f
feat(persistence): add PollScheduleJobs entity
SubochArtem Apr 7, 2026
b477e49
feat(infrastructure): add PollStatusJob
SubochArtem Apr 7, 2026
e36ad7d
refactor(infrastructure): replace direct DbContext usage with reposit…
SubochArtem Apr 11, 2026
03f0a19
feat(applcation): use ScheduleAsync in CreatePollCommandHandler
SubochArtem Apr 11, 2026
c5118d4
feat(infrastructure): add Hangfire configuration to DI
SubochArtem Apr 11, 2026
f95ae5a
feat(infrastructure): add HangFire migration
SubochArtem Apr 11, 2026
29d7216
feat(api): add HangFireDashboard
SubochArtem Apr 11, 2026
12cde09
feat(persistence): add EndsAtBefore to Poll filter
SubochArtem Apr 11, 2026
5b3c79b
feat(persistence): add GetExpiredAsync method to Poll repository
SubochArtem Apr 11, 2026
20b7bb6
feat(persistence): add PollCleanupJob
SubochArtem Apr 11, 2026
53b0fea
refactor(persistence): update formating in HangfirePollScheduler
SubochArtem Apr 11, 2026
b616701
refactor(persistence): update formating in DI
SubochArtem Apr 11, 2026
cd08eb2
Merge branch 'task/polls-user-controllers' into task/status-change-bg…
SubochArtem Apr 11, 2026
3c31449
refactor(infrastructure): improve PollCleanupJob batch processing loop
SubochArtem Apr 12, 2026
12f1120
refactor(infrastructure): update formating in Poll repository
SubochArtem Apr 12, 2026
8521756
feat(infrastructure): add cancellationToken to PollCleanupJob
SubochArtem Apr 19, 2026
3c95d07
refactor(api): delete needless filters from user controllers
SubochArtem Apr 20, 2026
5d58c82
refactor(api): remove needles [AllowAnonymous] atributs from controllers
SubochArtem Apr 27, 2026
6f296b8
refactor(infrastructure): rename Add and Remove to Create and Delete …
SubochArtem Apr 27, 2026
9519105
refactor(application): rename EndsAtBefore to EndsBefore in PollFilter
SubochArtem Apr 27, 2026
c67b31a
refactor(application): move endsAtChanged declaration closer to EndsA…
SubochArtem Apr 27, 2026
558df89
refactor(Jobs): move PollCleanupJob and PollStatusJob to application …
SubochArtem Apr 27, 2026
5a29453
Merge branch 'task/polls-user-controllers' into task/status-change-bg…
SubochArtem Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/Backend/Services/Polls/Polls.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
using Polls.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPresentation(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);

var app = builder.Build();

app.UsePresentation();
app.UseInfrastructure();

await app.RunAsync();
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,8 @@ Task UpdateStatusByCityAsync(
PollStatus target,
DateTimeOffset updatedAt,
CancellationToken cancellationToken = default);

Task<IReadOnlyList<Poll>> GetExpiredAsync(
int batchSize = 100,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Polls.Domain.PollScheduleJob;

namespace Polls.Application.Common.Interfaces;

public interface IPollScheduleJobRepository
{
Task<PollScheduleJob?> GetByPollIdAsync(
Guid pollId,
CancellationToken cancellationToken = default);

void Create(PollScheduleJob job);

void Delete(PollScheduleJob job);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Polls.Application.Common.Interfaces;

public interface IPollScheduler
{
Task ScheduleAsync(
Guid pollId,
DateTimeOffset endsAt,
CancellationToken cancellationToken = default);

Task CancelAsync(
Guid pollId,
CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ public interface IUnitOfWork
ICityRepository Cities { get; }
IPollRepository Polls { get; }
IIdeaRepository Ideas { get; }
IPollScheduleJobRepository PollScheduleJobs { get; }
Task SaveChangesAsync(CancellationToken cancellationToken = default);
Task<IUnitOfWorkTransaction> BeginTransactionAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public class PollFilter : BaseFilter
public Guid? CityId { get; set; }
public PollType? Type { get; set; }
public PollStatus? Status { get; set; }
public DateTimeOffset? EndsBefore { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using Polls.Application.Common.Interfaces;
using Polls.Domain.Polls.Enums;

namespace Polls.Application.Jobs;

public class PollCleanupJob(
IUnitOfWork unitOfWork)
{
public async Task ExecuteAsync(CancellationToken cancellationToken = default)
{
const int batchSize = 100;
var hasMore = true;

while (hasMore)
{
var expiredPolls = await unitOfWork.Polls.GetExpiredAsync(
batchSize,
cancellationToken);
hasMore = expiredPolls.Count == batchSize;

foreach (var poll in expiredPolls)
{
poll.Status = PollStatus.Inactive;
unitOfWork.Polls.Update(poll);
}

if (expiredPolls.Count > 0)
await unitOfWork.SaveChangesAsync(cancellationToken);
}
}
}
27 changes: 27 additions & 0 deletions src/Backend/Services/Polls/Polls.Application/Jobs/PollStatusJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Polls.Application.Common.Interfaces;
using Polls.Domain.Polls.Enums;

namespace Polls.Application.Jobs;

public class PollStatusJob(
IUnitOfWork unitOfWork)
{
public async Task ExecuteAsync(Guid pollId)
{
var poll = await unitOfWork.Polls.GetByIdAsync(pollId);
if (poll is null)
return;

if (poll.Status is not PollStatus.Active)
return;

poll.Status = PollStatus.Inactive;
unitOfWork.Polls.Update(poll);

var scheduleEntry = await unitOfWork.PollScheduleJobs.GetByPollIdAsync(pollId);
if (scheduleEntry is not null)
unitOfWork.PollScheduleJobs.Delete(scheduleEntry);

await unitOfWork.SaveChangesAsync();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ namespace Polls.Application.Polls.Commands.CreatePoll;

public sealed class CreatePollCommandHandler(
IUnitOfWork unitOfWork,
IMapper mapper)
IMapper mapper,
IPollScheduler pollScheduler)
: IRequestHandler<CreatePollCommand, Result<PollDto>>
{
public async Task<Result<PollDto>> Handle(
Expand All @@ -30,21 +31,18 @@ public async Task<Result<PollDto>> Handle(
var city = await unitOfWork.Cities.GetByIdAsync(
command.CityId,
cancellationToken);

if (city is null)
return CityErrors.NotFound(command.CityId);

var filter = new PollFilter
{
CityId = command.CityId,
Type = command.Type,
Status = PollStatus.Active
};

var activePolls = await unitOfWork.Polls.GetFilteredAsync(
filter,
cancellationToken);

if (activePolls.TotalCount > 0)
return PollErrors.AlreadyExists(command.CityId);

Expand All @@ -62,6 +60,8 @@ public async Task<Result<PollDto>> Handle(
unitOfWork.Polls.Create(poll);
await unitOfWork.SaveChangesAsync(cancellationToken);

await pollScheduler.ScheduleAsync(poll.Id, poll.EndsAt, cancellationToken);

return mapper.Map<PollDto>(poll);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
using Polls.Application.Polls.Guards;
using Polls.Domain.Common;
using Polls.Domain.Polls;
using Polls.Domain.Polls.Enums;

namespace Polls.Application.Polls.Commands.UpdatePoll;

public sealed class UpdatePollCommandHandler(
IUnitOfWork unitOfWork,
IMapper mapper)
IMapper mapper,
IPollScheduler pollScheduler)
: IRequestHandler<UpdatePollCommand, Result<PollDto>>
{
public async Task<Result<PollDto>> Handle(
Expand All @@ -37,15 +39,19 @@ public async Task<Result<PollDto>> Handle(
if (!guardResult.IsSuccess)
return guardResult.Error;
}

poll.Title = command.Title;
poll.Description = command.Description;
var endsAtChanged = poll.EndsAt != command.EndsAt;
poll.EndsAt = command.EndsAt;
poll.BudgetAmount = command.BudgetAmount;

unitOfWork.Polls.Update(poll);
await unitOfWork.SaveChangesAsync(cancellationToken);

if (endsAtChanged && poll.Status == PollStatus.Active)
await pollScheduler.ScheduleAsync(poll.Id, poll.EndsAt, cancellationToken);

return mapper.Map<PollDto>(poll);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Polls.Domain.PollScheduleJob;

public class PollScheduleJob
{
public Guid Id { get; set; }
public Guid PollId { get; set; }
public required string HangfireJobId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
using Hangfire;
using Hangfire.PostgreSql;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Polls.Application.Common.Interfaces;
using Polls.Application.Jobs;
using Polls.Infrastructure.Jobs;
using Polls.Infrastructure.Persistence;
using Polls.Infrastructure.Persistence.Interceptors;
using Polls.Infrastructure.Persistence.Options;
Expand Down Expand Up @@ -30,11 +36,8 @@ public static IServiceCollection AddInfrastructure(
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
{
var dbOptions = serviceProvider.GetRequiredService<IOptions<DatabaseOptions>>().Value;

var updateTimestampsInterceptor =
serviceProvider.GetRequiredService<SaveChangesInterceptor>();
var auditInterceptor =
serviceProvider.GetRequiredService<AuditInterceptor>();
var updateTimestampsInterceptor = serviceProvider.GetRequiredService<SaveChangesInterceptor>();
var auditInterceptor = serviceProvider.GetRequiredService<AuditInterceptor>();

options
.UseNpgsql(dbOptions.ConnectionString, npgsqlOptions =>
Expand All @@ -53,8 +56,42 @@ public static IServiceCollection AddInfrastructure(
.AddScoped<ICityRepository, CityRepository>()
.AddScoped<IPollRepository, PollRepository>()
.AddScoped<IIdeaRepository, IdeaRepository>()
.AddScoped<IPollScheduleJobRepository, PollScheduleJobRepository>()
.AddScoped<IUnitOfWork, UnitOfWork>();

services.AddHangfire((serviceProvider, config) =>
{
var dbOptions = serviceProvider.GetRequiredService<IOptions<DatabaseOptions>>().Value;

config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(dbOptions.ConnectionString));
});

services.AddHangfireServer();

services
.AddScoped<IPollScheduler, HangfirePollScheduler>()
.AddScoped<PollStatusJob>()
.AddScoped<PollCleanupJob>();

return services;
}

public static void UseInfrastructure(this WebApplication app)
{
const string hangfireDashboardPath = "/hangfire";
const string pollCleanupJobId = "poll-cleanup";

if (app.Environment.IsDevelopment())
app.UseHangfireDashboard(hangfireDashboardPath);

var recurringJobManager = app.Services.GetRequiredService<IRecurringJobManager>();
recurringJobManager.AddOrUpdate<PollCleanupJob>(
pollCleanupJobId,
job => job.ExecuteAsync(CancellationToken.None),
Cron.Hourly);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Hangfire;
using Polls.Application.Common.Interfaces;
using Polls.Application.Jobs;
using Polls.Domain.PollScheduleJob;

namespace Polls.Infrastructure.Jobs;

public class HangfirePollScheduler(
IUnitOfWork unitOfWork,
IBackgroundJobClient backgroundJobClient) : IPollScheduler
{
public async Task ScheduleAsync(
Guid pollId,
DateTimeOffset endsAt,
CancellationToken cancellationToken = default)
{
await CancelAsync(pollId, cancellationToken);

var delay = endsAt - DateTimeOffset.UtcNow;
if (delay < TimeSpan.Zero)
delay = TimeSpan.Zero;

var jobId = backgroundJobClient.Schedule<PollStatusJob>(
job => job.ExecuteAsync(pollId),
delay);

unitOfWork.PollScheduleJobs.Create(new PollScheduleJob
{
PollId = pollId,
HangfireJobId = jobId
});

await unitOfWork.SaveChangesAsync(cancellationToken);
}

public async Task CancelAsync(Guid pollId, CancellationToken cancellationToken = default)
{
var existing = await unitOfWork.PollScheduleJobs.GetByPollIdAsync(pollId, cancellationToken);
if (existing is null)
return;

backgroundJobClient.Delete(existing.HangfireJobId);
unitOfWork.PollScheduleJobs.Delete(existing);
await unitOfWork.SaveChangesAsync(cancellationToken);
}
}
Loading