From a2ae7c1e7eca66d915499349b343e07ae6f445f0 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Thu, 12 Feb 2026 18:34:55 +0500 Subject: [PATCH 1/4] phase 1 --- dotnet-skill.md | 1329 +++++++++++++++++ .../Surveys/PublicSurveyOptionTests.cs | 114 ++ .../Surveys/PublicSurveyQuestionTests.cs | 185 +++ .../Surveys/PublicSurveyResponseTests.cs | 101 ++ .../Entities/Surveys/PublicSurveyTests.cs | 315 ++++ src/Domain/Entities/Surveys/PublicSurvey.cs | 222 +++ .../Entities/Surveys/PublicSurveyOption.cs | 78 + .../Entities/Surveys/PublicSurveyQuestion.cs | 125 ++ .../Entities/Surveys/PublicSurveyResponse.cs | 63 + .../Surveys/PublicSurveyResponseOption.cs | 26 + src/Domain/Enums/PublicSurveyStatus.cs | 9 + .../PublicSurveyEntityConfiguration.cs | 45 + .../PublicSurveyOptionEntityConfiguration.cs | 29 + ...PublicSurveyQuestionEntityConfiguration.cs | 29 + ...PublicSurveyResponseEntityConfiguration.cs | 24 + ...SurveyResponseOptionEntityConfiguration.cs | 27 + .../Database/DatabaseContext.cs | 11 + src/TestUtils/Fakes/PublicSurveyFake.cs | 38 + src/TestUtils/Fakes/PublicSurveyOptionFake.cs | 17 + .../Fakes/PublicSurveyQuestionFake.cs | 19 + 20 files changed, 2806 insertions(+) create mode 100644 dotnet-skill.md create mode 100644 src/Domain.Tests/Entities/Surveys/PublicSurveyOptionTests.cs create mode 100644 src/Domain.Tests/Entities/Surveys/PublicSurveyQuestionTests.cs create mode 100644 src/Domain.Tests/Entities/Surveys/PublicSurveyResponseTests.cs create mode 100644 src/Domain.Tests/Entities/Surveys/PublicSurveyTests.cs create mode 100644 src/Domain/Entities/Surveys/PublicSurvey.cs create mode 100644 src/Domain/Entities/Surveys/PublicSurveyOption.cs create mode 100644 src/Domain/Entities/Surveys/PublicSurveyQuestion.cs create mode 100644 src/Domain/Entities/Surveys/PublicSurveyResponse.cs create mode 100644 src/Domain/Entities/Surveys/PublicSurveyResponseOption.cs create mode 100644 src/Domain/Enums/PublicSurveyStatus.cs create mode 100644 src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs create mode 100644 src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs create mode 100644 src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs create mode 100644 src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs create mode 100644 src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseOptionEntityConfiguration.cs create mode 100644 src/TestUtils/Fakes/PublicSurveyFake.cs create mode 100644 src/TestUtils/Fakes/PublicSurveyOptionFake.cs create mode 100644 src/TestUtils/Fakes/PublicSurveyQuestionFake.cs diff --git a/dotnet-skill.md b/dotnet-skill.md new file mode 100644 index 00000000..2823ec71 --- /dev/null +++ b/dotnet-skill.md @@ -0,0 +1,1329 @@ +# .NET Backend Best Practices Skill + +Use this before writing any .NET backend code, before planning code changes and enhancements. This establishes style guidelines, architecture patterns, and teaches essential .NET techniques derived from a production-grade Clean Architecture project. + +--- + +## Architecture + +### Clean Architecture Layers + +``` +Web.Api (Presentation Layer) + - Controllers, Features, Middlewares, Setup + - References: Infrastructure, Domain + ↓ +Infrastructure (Infrastructure Layer) + - Database (EF Core), External Services, AI, Email, Storage + - References: Domain + ↓ +Domain (Core Layer) + - Entities, Value Objects, Enums, Validation + - References: Nothing (zero external dependencies) +``` + +**Rules:** +- Domain MUST NOT reference Infrastructure or Web.Api +- Infrastructure MUST NOT reference Web.Api +- Web.Api references both Infrastructure and Domain +- Keep Domain free from framework dependencies (no EF Core, no ASP.NET) + +### Solution Structure + +``` +src/ +├── Web.Api/ # ASP.NET Core API host +│ ├── Features/ # Feature-based organization (controllers + handlers) +│ ├── Middlewares/ # HTTP pipeline middlewares +│ ├── Setup/ # DI, Auth, Health, Attributes, HostedServices +│ └── Services/ # Application-specific services +├── Infrastructure/ # Data access, external integrations +│ ├── Database/ # DbContext, Migrations, Entity Configs +│ ├── Services/ # Mediator, Correlation, HTTP, etc. +│ └── Extensions/ # Query extension methods +├── Domain/ # Core business logic +│ ├── Entities/ # Domain entities grouped by feature +│ ├── Enums/ # Enumerations +│ ├── ValueObjects/ # Immutable value types +│ ├── Validation/ # Validators, guard clauses, exceptions +│ ├── Attributes/ # Custom validation attributes +│ └── Extensions/ # Pure utility extensions +├── TestUtils/ # Shared test infrastructure +│ ├── Fakes/ # Entity builders/fakes +│ ├── Db/ # In-memory database context +│ └── Auth/ # Fake authentication helpers +├── Domain.Tests/ # Domain layer unit tests +├── InfrastructureTests/ # Infrastructure layer tests +└── Web.Api.Tests/ # API layer / integration tests +``` + +--- + +## Feature-Based Organization (CQRS-lite) + +### Pattern + +Each feature is a folder containing its handler, command/query, and response types. Controllers dispatch to handlers via a lightweight mediator. + +``` +Features/ +├── Salaries/ +│ ├── SalariesController.cs +│ ├── AddSalary/ +│ │ ├── AddSalaryCommand.cs +│ │ └── AddSalaryHandler.cs +│ ├── GetSalariesChart/ +│ │ ├── GetSalariesChartQuery.cs +│ │ └── GetSalariesChartHandler.cs +│ └── Models/ +│ └── UserSalaryDto.cs +``` + +### Mediator Interface + +Define a simple request handler interface in the Infrastructure layer: + +```csharp +namespace Infrastructure.Services.Mediator; + +public interface IRequestHandler +{ + Task Handle( + TRequest request, + CancellationToken cancellationToken); +} +``` + +### Mediator Dispatch Extension + +```csharp +public static class ServiceProviderExtensions +{ + public static async Task HandleBy( + this IServiceProvider serviceProvider, + TRequest request, + CancellationToken cancellationToken = default) + where THandler : IRequestHandler + { + var handler = serviceProvider.GetRequiredService(); + return await handler.Handle(request, cancellationToken); + } +} +``` + +### Auto-Registration of Handlers + +Register all `IRequestHandler<,>` implementations at startup using assembly scanning: + +```csharp +services.RegisterAllImplementations( + typeof(IRequestHandler<,>)); +``` + +Implementation: + +```csharp +public static IServiceCollection RegisterAllImplementations( + this IServiceCollection services, + Type openGenericType, + ServiceLifetime lifetime = ServiceLifetime.Scoped) +{ + var typesFromAssemblies = AppDomain.CurrentDomain + .GetAssemblies() + .Where(a => !a.IsDynamic) + .SelectMany(a => a.GetTypes()) + .Where(t => !t.IsAbstract && !t.IsInterface) + .Select(t => new + { + Implementation = t, + Services = t.GetInterfaces() + .Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == openGenericType) + }) + .Where(x => x.Services.Any()); + + foreach (var typeInfo in typesFromAssemblies) + { + foreach (var service in typeInfo.Services) + { + services.Add(new ServiceDescriptor(service, typeInfo.Implementation, lifetime)); + services.Add(new ServiceDescriptor(typeInfo.Implementation, typeInfo.Implementation, lifetime)); + } + } + + return services; +} +``` + +### Controller Pattern + +Controllers are thin dispatchers. They inject `IServiceProvider` and use `HandleBy`: + +```csharp +[ApiController] +[Route("api/salaries")] +public class SalariesController : ControllerBase +{ + private readonly IServiceProvider _serviceProvider; + + public SalariesController(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + [HttpGet("chart")] + public Task GetChart( + [FromQuery] GetSalariesChartQuery request, + CancellationToken cancellationToken) + { + return _serviceProvider.HandleBy( + request, + cancellationToken); + } + + [HttpPost("")] + [HasAnyRole] + public async Task Create( + [FromBody] AddSalaryCommand request, + CancellationToken cancellationToken) + { + return await _serviceProvider.HandleBy( + request, + cancellationToken); + } +} +``` + +### Handler Pattern + +Handlers contain all business logic. They receive dependencies via constructor injection: + +```csharp +public class AddSalaryHandler : IRequestHandler +{ + private readonly IAuthorization _auth; + private readonly DatabaseContext _context; + + public AddSalaryHandler( + IAuthorization auth, + DatabaseContext context) + { + _auth = auth; + _context = context; + } + + public async Task Handle( + AddSalaryCommand request, + CancellationToken cancellationToken) + { + request.IsValidOrFail(); + + var currentUser = await _auth.GetCurrentUserOrNullAsync(cancellationToken); + // ... business logic ... + + var salary = await _context.SaveAsync( + new UserSalary(/* ... */), + cancellationToken); + + return CreateOrEditSalaryRecordResponse.Success( + new UserSalaryDto(salary)); + } +} +``` + +### Command/Query Records + +Use `record` types for commands and queries. Include self-validation: + +```csharp +public record AddSalaryCommand : EditSalaryRequest +{ + public double Value { get; init; } + public int Quarter { get; init; } + public int Year { get; init; } + public Currency Currency { get; init; } + + public override void IsValidOrFail() + { + base.IsValidOrFail(); + + if (Value <= 0) + { + throw new BadRequestException("Value must be greater than 0"); + } + + if (Quarter is < 1 or > 4) + { + throw new BadRequestException("Quarter must be between 1 and 4"); + } + } +} +``` + +--- + +## Domain Layer Patterns + +### Entity Base Classes + +Use a hierarchy of base classes for entities: + +```csharp +// Audit timestamps interface +public interface IHasDates +{ + DateTimeOffset CreatedAt { get; } + DateTimeOffset UpdatedAt { get; } + void OnCreate(DateTimeOffset now); + void OnUpdate(DateTimeOffset now); +} + +// Abstract base with audit dates +public abstract class HasDatesBase : IHasDates +{ + public DateTimeOffset CreatedAt { get; protected set; } + public DateTimeOffset UpdatedAt { get; protected set; } + + public virtual void OnCreate(DateTimeOffset now) + { + CreatedAt = UpdatedAt = now; + } + + public virtual void OnUpdate(DateTimeOffset now) + { + UpdatedAt = now; + } +} + +// Generic ID interface +public interface IHasIdBase + where TKey : struct +{ + TKey Id { get; } +} + +// Standard long-based ID interface +public interface IHasId : IHasIdBase +{ +} + +// Soft-delete interface +public interface IHasDeletedAt +{ + DateTimeOffset? DeletedAt { get; } +} + +// Full base model: long Id + audit dates +public class BaseModel : HasDatesBase, IBaseModel +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + [Key] + public long Id { get; protected set; } + + public bool New() + { + return Id == default; + } +} +``` + +**Key convention:** Properties use `protected set` to enforce encapsulation. State changes happen through explicit methods, not through public setters. + +### Rich Domain Entities + +Entities contain business logic. Mutations happen through methods, not setters: + +```csharp +public class Company : HasDatesBase, IHasIdBase +{ + public Guid Id { get; protected set; } + public string Name { get; protected set; } + public string NormalizedName { get; protected set; } + // ... + + public Company( + string name, + string description, + List links, + string logoUrl) + { + name = name?.Trim(); + Name = name ?? throw new ArgumentNullException(nameof(name)); + NormalizedName = name.ToUpperInvariant(); + // ... validation and initialization in constructor + } + + public void AddReview(CompanyReview review) + { + NotDeletedOrFail(); + // ... business rules check ... + Reviews.Add(review); + } + + public void ApproveReview(Guid reviewId) + { + NotDeletedOrFail(); + var review = Reviews.FirstOrDefault(x => x.Id == reviewId) + ?? throw new NotFoundException("Review not found"); + + review.Approve(true); + var newRating = RecalculateRating(); + RatingHistory.Add(new CompanyRatingHistory(newRating, this)); + } +} +``` + +### Soft Delete Pattern + +```csharp +public static class ModelExtensions +{ + public static bool Active(this IHasDeletedAt deleted) + { + return deleted.DeletedAt == null; + } + + public static T ActiveOrFail(this T deleted) + where T : IHasDeletedAt, IHasId + { + if (deleted.Active()) + { + return deleted; + } + + throw new InvalidOperationException( + $"The entity of {typeof(T).Name} Id:{deleted.Id} has been deleted already"); + } +} +``` + +### Value Objects + +Use `record` types for immutable value objects. Place in `Domain/ValueObjects/`: + +```csharp +public record Pageable : PaginatedListBase +{ + public IReadOnlyCollection Results { get; protected set; } + + public Pageable( + int currentPage, + int pageSize, + int totalItems, + IReadOnlyCollection results) + { + CurrentPage = currentPage; + PageSize = pageSize; + TotalItems = totalItems; + Results = results; + } +} + +public record CurrentUser +{ + public string UserId { get; protected set; } + public string Email { get; protected set; } + public IReadOnlyCollection Roles { get; protected set; } + + public bool Has(Role role) => Roles.Contains(role); + + public void HasOrFail(Role role) + { + if (!Has(role)) + { + throw new UnauthorizedAccessException($"User does not have {role} role"); + } + } +} +``` + +--- + +## Validation & Exception Handling + +### Guard Clause Extensions + +Define fluent guard clauses as extension methods in `Domain/Validation/`: + +```csharp +public static class ValidateUtilities +{ + public static T ThrowIfNull(this T instance, string paramName, string customErrorMessage = null) + { + if (string.IsNullOrEmpty(paramName)) + { + throw new InvalidOperationException("You should not pass null or empty string a paramName"); + } + + if (instance != null) + { + return instance; + } + + throw customErrorMessage.NullOrEmpty() + ? new ArgumentNullException(paramName: paramName) + : new ArgumentNullException(paramName: paramName, message: customErrorMessage); + } + + public static IReadOnlyCollection ThrowIfNullOrEmpty( + this IReadOnlyCollection collection, string paramName) + { + paramName.ThrowIfNullOrEmpty(nameof(paramName)); + collection.ThrowIfNull(paramName); + + if (!collection.Any()) + { + throw new InvalidOperationException($"You should not pass empty collection: {paramName}"); + } + + return collection; + } + + public static string ThrowIfNullOrEmpty(this string @string, string paramName) + { + if (@string.NullOrEmpty()) + { + throw new BadRequestException("You should not pass null or empty string for " + paramName); + } + + return @string; + } +} +``` + +### Entity Validation via Data Annotations + +Validate entities using `[ValidationAttribute]` and a generic validator: + +```csharp +public class EntityValidator +{ + private readonly T _entity; + private readonly ICollection _errors; + + public EntityValidator(T entity) { /* ... */ } + + public void ThrowIfInvalid() + { + if (!Valid()) + { + throw EntityInvalidException.FromInstance(_errors); + } + } +} + +// Usage as extension: +public static T ThrowIfInvalid(this T entity) +{ + new EntityValidator(entity).ThrowIfInvalid(); + return entity; +} +``` + +### Custom Domain Exceptions + +Define a hierarchy of domain exceptions that map to HTTP status codes: + +```csharp +// 400 Bad Request - invalid client request +public class BadRequestException : InvalidOperationException +{ + public BadRequestException(string message) : base(message) { } +} + +// 400 Bad Request - entity validation failed +public class EntityInvalidException : InvalidOperationException +{ + public static EntityInvalidException FromInstance(ICollection errors) { /* ... */ } +} + +// 404 Not Found +public class NotFoundException : Exception +{ + public NotFoundException(string message) : base(message) { } + + // Factory methods for consistent messages + public static NotFoundException CreateFromEntity(long id) + { + return new($"Did not find any {typeof(TEntity).Name} by id={id}"); + } + + public static NotFoundException CreateFromEntity(Guid id) + { + return new($"Did not find any {typeof(TEntity).Name} by id={id}"); + } +} + +// 403 Forbidden +public class NoPermissionsException : Exception +{ + public NoPermissionsException(string message) : base(message) { } + public NoPermissionsException() : this("The user has no permissions to do this operation") { } +} + +// 422 Unprocessable Entity +public class InputValidationException : InvalidOperationException { /* ... */ } + +// 409 Conflict +public class DbUpdateConcurrencyException : InvalidOperationException { /* ... */ } + +// Wraps raw database errors +public class DatabaseException : InvalidOperationException { /* ... */ } +``` + +### Exception-to-HTTP Middleware + +Map domain exceptions to HTTP status codes in middleware: + +```csharp +public class ExceptionHttpMiddleware +{ + internal static readonly Dictionary StatusCodeTypes = new() + { + { typeof(UnauthorizedAccessException), StatusCodes.Status401Unauthorized }, + { typeof(AuthenticationException), StatusCodes.Status401Unauthorized }, + { typeof(NoPermissionsException), StatusCodes.Status403Forbidden }, + { typeof(NotFoundException), StatusCodes.Status404NotFound }, + { typeof(BadRequestException), StatusCodes.Status400BadRequest }, + { typeof(EntityInvalidException), StatusCodes.Status400BadRequest }, + { typeof(InputValidationException), StatusCodes.Status422UnprocessableEntity }, + { typeof(InvalidOperationException), StatusCodes.Status400BadRequest }, + { typeof(DbUpdateConcurrencyException), StatusCodes.Status409Conflict } + }; + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception exception) + { + await HandleExceptionAsync(context, exception); + } + } + + private async Task HandleExceptionAsync(HttpContext context, Exception exception) + { + var statusCode = StatusCodes.Status500InternalServerError; + string message = null; + + if (StatusCodeTypes.TryGetValue(exception.GetType(), out int status)) + { + statusCode = status; + message = exception.Message; + } + + await WriteResponseAsync(context, statusCode, message, exception); + } +} +``` + +--- + +## Infrastructure Patterns + +### Database Context + +Extend `DbContext` with helper methods for save operations and automatic audit timestamps: + +```csharp +public class DatabaseContext : DbContext +{ + public DbSet Users { get; set; } + public DbSet Salaries { get; set; } + // ... + + // Save with entity validation + public virtual async Task SaveAsync( + TEntity entity, + CancellationToken cancellationToken = default) + where TEntity : class + { + entity.ThrowIfNull(nameof(entity)); + entity.ThrowIfInvalid(); + + var entry = await AddAsync(entity, cancellationToken); + await TrySaveChangesAsync(cancellationToken); + + return entry.Entity; + } + + // Wrap SaveChanges to catch DB errors + public async Task TrySaveChangesAsync(CancellationToken cancellationToken = default) + { + try + { + return await SaveChangesAsync(cancellationToken); + } + catch (Exception exception) + { + throw new DatabaseException(exception); + } + } + + // Transaction helper + public async Task DoWithinTransactionAsync( + Func> action, + string errorMessage = null, + CancellationToken cancellationToken = default) + { + try + { + await Database.BeginTransactionAsync(cancellationToken); + var result = await action(); + await Database.CommitTransactionAsync(cancellationToken); + return result; + } + catch (Exception exception) + { + await Database.RollbackTransactionAsync(cancellationToken); + throw new InvalidOperationException(errorMessage ?? "Cannot execute transaction", exception); + } + } + + // Automatic audit timestamps + protected virtual void OnBeforeSaving() + { + var entries = ChangeTracker.Entries(); + var currentDateTime = DateTimeOffset.Now; + + foreach (EntityEntry entry in entries) + { + switch (entry.State) + { + case EntityState.Modified: + entry.Entity.OnUpdate(currentDateTime); + break; + case EntityState.Added: + entry.Entity.OnCreate(currentDateTime); + break; + } + } + } + + // Apply all entity configurations from assembly + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + builder.ApplyConfigurationsFromAssembly(typeof(DatabaseContext).Assembly); + } +} +``` + +### Entity Type Configuration + +Use `IEntityTypeConfiguration` for each entity. Place in `Infrastructure/Database/Config/`: + +```csharp +public class UserSalaryConfig : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("UserSalaries"); + builder.HasKey(x => x.Id); + + builder + .HasOne(x => x.User) + .WithMany(x => x.Salaries) + .HasForeignKey(x => x.UserId) + .OnDelete(DeleteBehavior.SetNull); + + builder + .Property(x => x.UseInStats) + .HasDefaultValue(true); + } +} +``` + +### Query Extensions + +Place reusable query logic as extension methods on `IQueryable`: + +```csharp +public static class UserExtensions +{ + public static Task> GetUserRelevantSalariesAsync( + this IQueryable salaries, + long userId, + CancellationToken cancellationToken) + { + var currentQuarter = DateQuarter.Current; + return salaries + .Where(x => x.UserId == userId) + .Where(x => x.Year == currentQuarter.Year || x.Year == currentQuarter.Year - 1) + .AsNoTracking() + .OrderByDescending(x => x.Year) + .ThenByDescending(x => x.Quarter) + .ToListAsync(cancellationToken); + } +} +``` + +--- + +## Middleware Pipeline + +Order matters. Configure in this sequence: + +```csharp +public void Configure(IApplicationBuilder app) +{ + // 1. Correlation ID (first - ensures all logs have correlation) + app.UseMiddleware(); + + // 2. Exception handler (catches everything downstream) + app.UseMiddleware(); + + // 3. Logging (logs exceptions, then re-throws for #2 to handle) + app.UseLoggingMiddleware(); + + // 4. Standard ASP.NET middleware + app.UseSwagger(); + app.UseHttpsRedirection(); + app.UseRouting(); + app.UseCors(CorsPolicyName); + app.UseSession(); + app.UseAuthentication(); + app.UseAuthorization(); + + // 5. Health checks + HealthCheckCustomResponse.Setup(app); + + // 6. Endpoints + app.UseEndpoints(endpoints => endpoints.MapControllers()); + + // 7. Catch-all 404 (last) + app.UseMiddleware(); +} +``` + +### Correlation ID Middleware + +Propagate or generate a correlation ID per request: + +```csharp +public class CorrelationIdMiddleware +{ + public async Task InvokeAsync(HttpContext context) + { + var correlationId = GetCorrelationId(context); + context.Items.TryAdd(CorrelationIdAccessor.CorrelationIdHeaderName, correlationId); + + context.Response.OnStarting(() => + { + context.Response.Headers.Append( + CorrelationIdAccessor.CorrelationIdHeaderName, correlationId); + return Task.CompletedTask; + }); + + await _nextInvoke(context); + } +} +``` + +### Selective Logging Middleware + +Log errors but skip expected exceptions: + +```csharp +public class LoggingMiddleware +{ + private readonly IReadOnlyCollection _exceptionsToIgnore = new List + { + typeof(AuthenticationException), + typeof(BadRequestException), + typeof(EntityInvalidException), + typeof(NotFoundException), + }; + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (Exception exception) + { + if (!_exceptionsToIgnore.Contains(exception.GetType())) + { + _logger.LogError(exception, "Unhandled error. Message {Message}", exception.Message); + } + + throw; // Re-throw for ExceptionHttpMiddleware to handle + } + } +} +``` + +--- + +## Authorization Patterns + +### Custom Authorization Attribute + +```csharp +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public class HasAnyRoleAttribute : Attribute, IAuthorizationFilter +{ + public IReadOnlyCollection RolesToCheck { get; } + + public HasAnyRoleAttribute(params Role[] rolesToCheck) + { + RolesToCheck = rolesToCheck; + } + + public void OnAuthorization(AuthorizationFilterContext context) + { + var hasAuth = context.HttpContext.User.Claims.Any(); + if (!hasAuth) + { + throw new AuthenticationException("You have to be authorized"); + } + + if (!RolesToCheck.Any() || new CurrentUser(context.HttpContext.User).HasAny(RolesToCheck)) + { + return; + } + + throw new NoPermissionsException("You are not allowed to interact with this action"); + } +} +``` + +Usage: +```csharp +[HasAnyRole] // Any authenticated user +[HasAnyRole(Role.Admin)] // Admin only +[HasAnyRole(Role.Admin, Role.HR)] // Admin or HR +``` + +--- + +## Dependency Injection + +### Registration Pattern + +Use extension methods on `IServiceCollection` grouped by concern: + +```csharp +public static class ServiceRegistration +{ + public static IServiceCollection SetupAppServices( + this IServiceCollection services, + IConfiguration configuration) + { + services.AddHttpContextAccessor() + .AddTransient() + .AddScoped() + .AddScoped() + // ... + ; + + return services; + } +} +``` + +### Startup Composition + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services + .SetupDatabase(_configuration, _environment) + .SetupAppServices(_configuration) + .SetupEmailIntegration(_environment, _configuration) + .SetupHealthCheck(_configuration) + .SetupAuthentication(_configuration) + .SetupScheduler() + .RegisterAllImplementations(typeof(IRequestHandler<,>)); + + services.AddHostedService(); +} +``` + +### Lifetime Guidelines + +| Lifetime | Use For | +|----------|---------| +| `Transient` | Stateless utilities, HTTP clients, lightweight services | +| `Scoped` | DbContext, Authorization, services that hold per-request state | +| `Singleton` | Configuration objects, PDF converters | + +--- + +## Configuration + +### Typed Configuration via Constructor + +```csharp +public record Global : IGlobal +{ + public string AppName { get; } + public string FrontendBaseUrl { get; } + public string BackendBaseUrl { get; } + + public Global(IConfiguration configuration) + { + AppName = configuration["Global:AppName"]; + FrontendBaseUrl = configuration["Global:Frontend"]; + BackendBaseUrl = configuration["Global:Backend"]; + } + + public string InterviewWebLink(Guid id) => $"{FrontendBaseUrl}/interviews/{id}"; +} +``` + +--- + +## Testing + +### Framework + +- **xUnit** for test framework +- **Moq** for mocking +- **Faker.Net** for test data generation +- In-memory EF Core database for integration tests + +### Test Organization + +Mirror the source structure: + +``` +Web.Api.Tests/Features/Salaries/AddSalary/AddSalaryHandlerTests.cs +Domain.Tests/ValueObjects/DateQuarterTests.cs +InfrastructureTests/Salaries/JobPostingParserTests.cs +``` + +### Test Naming Convention + +```csharp +[Fact] +public async Task MethodName_StateUnderTest_ExpectedBehavior() + +// Examples: +public async Task Handle_NoExistingRecordForDate_ValidData_CreatesNewRecord() +public async Task Handle_ExistingRecordForDate_ValidData_ThrowsBadRequestException() +public async Task GetUser_UserHimself_ReturnsSalaries() +``` + +Every test file MUST have at least one happy path test. + +### Fake/Builder Pattern + +Create fakes that inherit from domain entities: + +```csharp +public class CurrenciesCollectionFake : CurrenciesCollection +{ + public CurrenciesCollectionFake( + DateTime currencyDate, + Dictionary currencies = null) + : base(currencies ?? CreateDefaultCurrencies(currencyDate)) + { + } + + // Persist to in-memory DB + public CurrenciesCollection Please(InMemoryDatabaseContext context) + { + var entry = context.Add((CurrenciesCollection)this); + context.SaveChanges(); + return entry.Entity; + } +} +``` + +Usage patterns: +```csharp +// Sync save +var company = new CompanyFake().Please(context); + +// Async save +var user = await new UserFake(Role.Admin).PleaseAsync(context); +var salary = await new UserSalaryFake(user, 400_000).PleaseAsync(context); +``` + +### Integration Test Pattern + +Test handlers directly with in-memory database: + +```csharp +[Fact] +public async Task Handle_NoExistingRecordForDate_ValidData_CreatesNewRecord() +{ + // Arrange + await using var context = new InMemoryDatabaseContext(); + var handler = new CreateCurrenciesCollectionHandler(context); + + var request = new CreateCurrenciesCollectionRequest + { + CurrencyDate = new DateTime(2024, 1, 15), + Currencies = new Dictionary + { + { Currency.USD, 450.50 }, + { Currency.EUR, 520.75 } + } + }; + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Currencies.Count); + + var createdRecord = await context.CurrencyCollections + .FirstOrDefaultAsync(x => x.CurrencyDate == request.CurrencyDate); + Assert.NotNull(createdRecord); +} +``` + +### Controller Test Pattern + +Test controllers using fakes for auth: + +```csharp +[Fact] +public async Task GetUser_UserHimself_ReturnsSalaries() +{ + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var salary = await new UserSalaryFake(user, 400_000).PleaseAsync(context); + + var controller = new UsersController( + new FakeAuth(user), + context); + + context.ChangeTracker.Clear(); + var result = await controller.GetUser(user.Id, default); + Assert.Equal(user.Id, result.Id); +} +``` + +### Theory Tests + +Use `[Theory]` with `[InlineData]` for parameterized tests: + +```csharp +[Theory] +[InlineData("Text with salary 500000-800000", 500_000d, 800_000d)] +[InlineData("Salary from 200k", 200_000d, null)] +[InlineData("No salary info", null, null)] +public void GetResult_HasSalariesInText_Ok( + string text, + double? min, + double? max) +{ + var result = new JobPostingParser(text).GetResult(); + Assert.Equal(min, result.MinSalary); + Assert.Equal(max, result.MaxSalary); +} +``` + +--- + +## Code Style Rules + +### StyleCop & Code Analysis + +Enforce via `Directory.Build.props` with `standard.ruleset`. Warnings are treated as errors in CI: + +```xml + + true + +``` + +### stylecop.json + +```json +{ + "settings": { + "documentationRules": { + "xmlHeader": false, + "fileNamingConvention": "metadata" + }, + "orderingRules": { + "usingDirectivesPlacement": "outsideNamespace", + "elementOrder": ["constant", "readonly"] + }, + "layoutRules": { + "newlineAtEndOfFile": "require" + } + } +} +``` + +### Naming Conventions + +| Element | Convention | Example | +|---------|-----------|---------| +| Private fields | `_camelCase` | `_dbContext`, `_auth` | +| Async methods | `PascalCaseAsync` | `GetInterviewAsync` | +| Interfaces | `IPascalCase` | `IInterviewService` | +| Local variables/params | `camelCase` | `interviewId` | +| Constants | `PascalCase` | `CorsPolicyName` | +| File-scoped namespaces | Always | `namespace Domain.Entities;` | +| Records for DTOs | `PascalCase` | `record AuthTokenResponse` | + +### Key StyleCop Rules (enforced as errors) + +- **SA1101: None** - `this.` prefix NOT required +- **SA1309: None** - `_` prefix for fields IS allowed +- **SA1200: Error** - Using directives outside namespace +- **SA1201: Error** - Elements must appear in correct order +- **SA1402: Error** - One type per file +- **SA1500-SA1520: Error** - Braces and spacing rules +- **SA1600-SA1602: None** - XML documentation NOT required +- **SA1633-SA1652: None** - File headers NOT required + +### Disabled Rules (intentionally relaxed) + +- SA1101 (no `this.` prefix required) +- SA1118 (multiline parameters allowed) +- SA1305 (Hungarian notation check disabled) +- SA1309 (underscore prefix for fields allowed) +- SA1412 (encoding check disabled) +- SA1413 (trailing comma check disabled) +- SA1518 (end-of-file newline handling relaxed) +- SA1600-1602 (XML docs not required on all public types) +- SA1633-1652 (file headers not required) +- SA1649 (file name doesn't have to match first type) + +--- + +## Database Migrations + +**NEVER generate or modify migrations automatically.** Migrations are created manually. + +- Never modify `*DatabaseContextModelSnapshot.cs` files +- Never create/modify files in `Infrastructure/Migrations/` +- When changing entity models, inform the developer to run: `dotnet ef migrations add ` + +--- + +## Response Patterns + +### Success/Failure Response + +```csharp +public record CreateOrEditSalaryRecordResponse +{ + public bool Success { get; init; } + public string ErrorMessage { get; init; } + public UserSalaryDto Salary { get; init; } + + public static CreateOrEditSalaryRecordResponse Success(UserSalaryDto salary) => + new() { Success = true, Salary = salary }; + + public static CreateOrEditSalaryRecordResponse Failure(string message) => + new() { Success = false, ErrorMessage = message }; +} +``` + +### DTO Pattern + +Use records with `[JsonPropertyName]` when needed: + +```csharp +public record AuthTokenResponse +{ + [JsonPropertyName("access_token")] + public string AccessToken { get; init; } + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; init; } + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; init; } + + [JsonPropertyName("token_type")] + public string TokenType { get; init; } = "Bearer"; +} +``` + +--- + +## Background Services + +### Base Class + +```csharp +public abstract class BackgroundServiceBase : IHostedService, IDisposable +{ + private readonly CancellationTokenSource _stoppingCts = new(); + private Task _executingTask; + + protected abstract Task ExecuteAsync(CancellationToken stoppingToken); + + public virtual Task StartAsync(CancellationToken cancellationToken) + { + _executingTask = ExecuteAsync(_stoppingCts.Token); + return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask; + } + + public virtual async Task StopAsync(CancellationToken cancellationToken) + { + if (_executingTask == null) return; + try + { + await _stoppingCts.CancelAsync(); + } + finally + { + await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, cancellationToken)); + } + } + + public void Dispose() + { + _stoppingCts.Cancel(); + _stoppingCts.Dispose(); + GC.SuppressFinalize(this); + } +} +``` + +--- + +## Custom Validation Attributes + +Place in `Domain/Attributes/` for reusable validation logic: + +```csharp +public class NotDefaultValueAttribute : ValidationAttribute +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (value == null) return ValidationResult.Success; + + var type = value.GetType(); + if (type.IsValueType) + { + var defaultValue = Activator.CreateInstance(type); + return !value.Equals(defaultValue) + ? ValidationResult.Success + : new ValidationResult(FormatError(validationContext)); + } + + return ValidationResult.Success; + } +} +``` + +--- + +## Verification Checklist + +Before completing any task: + +- [ ] `dotnet build` succeeds with zero warnings +- [ ] All existing tests pass (`dotnet test`) +- [ ] New code has tests (at least one happy path) +- [ ] No migrations generated or modified +- [ ] Code follows `standard.ruleset` (StyleCop enforced) +- [ ] Entities use `protected set` for properties +- [ ] Business logic lives in entities or handlers, not controllers +- [ ] Domain exceptions used instead of returning error codes +- [ ] `CancellationToken` passed through async call chains +- [ ] Guard clauses used for parameter validation (`ThrowIfNull`, `ThrowIfNullOrEmpty`) diff --git a/src/Domain.Tests/Entities/Surveys/PublicSurveyOptionTests.cs b/src/Domain.Tests/Entities/Surveys/PublicSurveyOptionTests.cs new file mode 100644 index 00000000..06dcd704 --- /dev/null +++ b/src/Domain.Tests/Entities/Surveys/PublicSurveyOptionTests.cs @@ -0,0 +1,114 @@ +using System; +using Domain.Entities.Surveys; +using Domain.Validation.Exceptions; +using TestUtils.Fakes; +using Xunit; + +namespace Domain.Tests.Entities.Surveys; + +public class PublicSurveyOptionTests +{ + [Fact] + public void Constructor_ValidParams_CreatesOption() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = new PublicSurveyOptionFake(question, text: "Yes", order: 1); + + Assert.Equal("Yes", option.Text); + Assert.Equal(1, option.Order); + Assert.Equal(question.Id, option.QuestionId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrEmptyText_Throws(string text) + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + Assert.Throws(() => + new PublicSurveyOption(text, 0, question)); + } + + [Fact] + public void Constructor_TextTooLong_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var longText = new string('a', PublicSurveyOption.TextMaxLength + 1); + + Assert.Throws(() => + new PublicSurveyOptionFake(question, text: longText)); + } + + [Fact] + public void Constructor_NegativeOrder_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + Assert.Throws(() => + new PublicSurveyOptionFake(question, order: -1)); + } + + [Fact] + public void UpdateText_ValidText_UpdatesText() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = new PublicSurveyOptionFake(question); + + option.UpdateText("Updated option"); + + Assert.Equal("Updated option", option.Text); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UpdateText_NullOrEmpty_Throws(string text) + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = new PublicSurveyOptionFake(question); + + Assert.Throws(() => option.UpdateText(text)); + } + + [Fact] + public void UpdateText_TooLong_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = new PublicSurveyOptionFake(question); + + Assert.Throws(() => + option.UpdateText(new string('a', PublicSurveyOption.TextMaxLength + 1))); + } + + [Fact] + public void UpdateOrder_ValidOrder_UpdatesOrder() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = new PublicSurveyOptionFake(question); + + option.UpdateOrder(3); + + Assert.Equal(3, option.Order); + } + + [Fact] + public void UpdateOrder_NegativeOrder_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = new PublicSurveyOptionFake(question); + + Assert.Throws(() => option.UpdateOrder(-1)); + } +} diff --git a/src/Domain.Tests/Entities/Surveys/PublicSurveyQuestionTests.cs b/src/Domain.Tests/Entities/Surveys/PublicSurveyQuestionTests.cs new file mode 100644 index 00000000..b8e3a17c --- /dev/null +++ b/src/Domain.Tests/Entities/Surveys/PublicSurveyQuestionTests.cs @@ -0,0 +1,185 @@ +using System; +using Domain.Entities.Surveys; +using Domain.Validation.Exceptions; +using TestUtils.Fakes; +using Xunit; + +namespace Domain.Tests.Entities.Surveys; + +public class PublicSurveyQuestionTests +{ + [Fact] + public void Constructor_ValidParams_CreatesQuestion() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey, text: "What is your role?", order: 1); + + Assert.Equal("What is your role?", question.Text); + Assert.Equal(1, question.Order); + Assert.False(question.AllowMultipleChoices); + Assert.Equal(survey.Id, question.PublicSurveyId); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrEmptyText_Throws(string text) + { + var survey = new PublicSurveyFake(); + + Assert.Throws(() => + new PublicSurveyQuestion(text, 0, survey)); + } + + [Fact] + public void Constructor_TextTooLong_Throws() + { + var survey = new PublicSurveyFake(); + var longText = new string('a', PublicSurveyQuestion.TextMaxLength + 1); + + Assert.Throws(() => + new PublicSurveyQuestionFake(survey, text: longText)); + } + + [Fact] + public void Constructor_NegativeOrder_Throws() + { + var survey = new PublicSurveyFake(); + + Assert.Throws(() => + new PublicSurveyQuestionFake(survey, order: -1)); + } + + [Fact] + public void AddOption_ValidOption_AddsToList() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + var option = question.AddOption("Option A", 0); + + Assert.Single(question.Options); + Assert.Equal("Option A", option.Text); + } + + [Fact] + public void AddOption_ExceedsMaxOptions_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + for (int i = 0; i < PublicSurveyQuestion.MaxOptions; i++) + { + question.AddOption($"Option {i}", i); + } + + Assert.Throws(() => + question.AddOption("One too many", PublicSurveyQuestion.MaxOptions)); + } + + [Fact] + public void RemoveOption_BelowMinOptions_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + question.AddOption("Option A", 0); + var optionB = question.AddOption("Option B", 1); + + Assert.Throws(() => + question.RemoveOption(optionB.Id)); + } + + [Fact] + public void RemoveOption_AboveMinOptions_RemovesOption() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + question.AddOption("Option A", 0); + question.AddOption("Option B", 1); + var optionC = question.AddOption("Option C", 2); + + question.RemoveOption(optionC.Id); + + Assert.Equal(2, question.Options.Count); + } + + [Fact] + public void RemoveOption_NonExistentOption_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + question.AddOption("Option A", 0); + question.AddOption("Option B", 1); + question.AddOption("Option C", 2); + + Assert.Throws(() => + question.RemoveOption(Guid.NewGuid())); + } + + [Fact] + public void HasValidOptions_LessThanMin_ReturnsFalse() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + question.AddOption("Only one", 0); + + Assert.False(question.HasValidOptions()); + } + + [Fact] + public void HasValidOptions_AtLeastMin_ReturnsTrue() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + question.AddOption("Option A", 0); + question.AddOption("Option B", 1); + + Assert.True(question.HasValidOptions()); + } + + [Fact] + public void UpdateText_ValidText_UpdatesText() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + question.UpdateText("Updated question?"); + + Assert.Equal("Updated question?", question.Text); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void UpdateText_NullOrEmpty_Throws(string text) + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + Assert.Throws(() => question.UpdateText(text)); + } + + [Fact] + public void UpdateOrder_ValidOrder_UpdatesOrder() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + question.UpdateOrder(5); + + Assert.Equal(5, question.Order); + } + + [Fact] + public void SetAllowMultipleChoices_True_SetsValue() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + question.SetAllowMultipleChoices(true); + + Assert.True(question.AllowMultipleChoices); + } +} diff --git a/src/Domain.Tests/Entities/Surveys/PublicSurveyResponseTests.cs b/src/Domain.Tests/Entities/Surveys/PublicSurveyResponseTests.cs new file mode 100644 index 00000000..3ccafe7b --- /dev/null +++ b/src/Domain.Tests/Entities/Surveys/PublicSurveyResponseTests.cs @@ -0,0 +1,101 @@ +using System; +using Domain.Entities.Surveys; +using Domain.Validation.Exceptions; +using TestUtils.Fakes; +using Xunit; + +namespace Domain.Tests.Entities.Surveys; + +public class PublicSurveyResponseTests +{ + [Fact] + public void Constructor_ValidParams_CreatesResponse() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + var response = new PublicSurveyResponse(question, userId: 1); + + Assert.Equal(question.Id, response.QuestionId); + Assert.Equal(1, response.UserId); + Assert.Empty(response.SelectedOptions); + } + + [Fact] + public void Constructor_NullQuestion_Throws() + { + Assert.Throws(() => + new PublicSurveyResponse(null, userId: 1)); + } + + [Fact] + public void Constructor_ZeroUserId_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + Assert.Throws(() => + new PublicSurveyResponse(question, userId: 0)); + } + + [Fact] + public void Constructor_NegativeUserId_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + + Assert.Throws(() => + new PublicSurveyResponse(question, userId: -1)); + } + + [Fact] + public void AddSelectedOption_ValidOption_AddsToList() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = question.AddOption("Option A", 0); + var response = new PublicSurveyResponse(question, userId: 1); + + response.AddSelectedOption(option); + + Assert.Single(response.SelectedOptions); + } + + [Fact] + public void AddSelectedOption_NullOption_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var response = new PublicSurveyResponse(question, userId: 1); + + Assert.Throws(() => + response.AddSelectedOption(null)); + } + + [Fact] + public void AddSelectedOption_OptionFromDifferentQuestion_Throws() + { + var survey = new PublicSurveyFake(); + var question1 = new PublicSurveyQuestionFake(survey, order: 0); + var question2 = new PublicSurveyQuestionFake(survey, order: 1); + var optionFromQ2 = question2.AddOption("Option from Q2", 0); + var response = new PublicSurveyResponse(question1, userId: 1); + + Assert.Throws(() => + response.AddSelectedOption(optionFromQ2)); + } + + [Fact] + public void AddSelectedOption_DuplicateOption_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + var option = question.AddOption("Option A", 0); + var response = new PublicSurveyResponse(question, userId: 1); + + response.AddSelectedOption(option); + + Assert.Throws(() => + response.AddSelectedOption(option)); + } +} diff --git a/src/Domain.Tests/Entities/Surveys/PublicSurveyTests.cs b/src/Domain.Tests/Entities/Surveys/PublicSurveyTests.cs new file mode 100644 index 00000000..e1beeb81 --- /dev/null +++ b/src/Domain.Tests/Entities/Surveys/PublicSurveyTests.cs @@ -0,0 +1,315 @@ +using System; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Fakes; +using Xunit; + +namespace Domain.Tests.Entities.Surveys; + +public class PublicSurveyTests +{ + [Fact] + public void Constructor_ValidParams_CreatesDraftSurvey() + { + var survey = new PublicSurveyFake(authorId: 1, title: "My Survey", slug: "my-survey"); + + Assert.Equal("My Survey", survey.Title); + Assert.Equal("my-survey", survey.Slug); + Assert.Equal(1, survey.AuthorId); + Assert.Equal(PublicSurveyStatus.Draft, survey.Status); + Assert.Null(survey.PublishedAt); + Assert.Null(survey.DeletedAt); + Assert.True(survey.IsDraft()); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrEmptyTitle_Throws(string title) + { + Assert.Throws(() => + new PublicSurvey(title, "desc", "valid-slug", 1)); + } + + [Fact] + public void Constructor_TitleTooLong_Throws() + { + var longTitle = new string('a', 501); + + Assert.Throws(() => + new PublicSurveyFake(title: longTitle)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Constructor_NullOrEmptySlug_Throws(string slug) + { + Assert.ThrowsAny(() => + new PublicSurvey("Valid Title", "desc", slug, 1)); + } + + [Fact] + public void Constructor_ZeroAuthorId_Throws() + { + Assert.Throws(() => + new PublicSurveyFake(authorId: 0)); + } + + [Fact] + public void Publish_DraftWithValidQuestions_BecomesPublished() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + survey.Questions.Add(question); + question.AddOption("Option A", 0); + question.AddOption("Option B", 1); + + survey.Publish(); + + Assert.Equal(PublicSurveyStatus.Published, survey.Status); + Assert.NotNull(survey.PublishedAt); + Assert.True(survey.IsPublished()); + Assert.True(survey.CanAcceptResponses()); + } + + [Fact] + public void Publish_DraftWithNoQuestions_Throws() + { + var survey = new PublicSurveyFake(); + + Assert.Throws(() => survey.Publish()); + } + + [Fact] + public void Publish_DraftWithQuestionLessThanTwoOptions_Throws() + { + var survey = new PublicSurveyFake(); + var question = new PublicSurveyQuestionFake(survey); + survey.Questions.Add(question); + question.AddOption("Only one", 0); + + Assert.Throws(() => survey.Publish()); + } + + [Fact] + public void Publish_AlreadyPublished_Throws() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + Assert.Throws(() => survey.Publish()); + } + + [Fact] + public void Close_Published_BecomesClosed() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + survey.Close(); + + Assert.Equal(PublicSurveyStatus.Closed, survey.Status); + Assert.False(survey.CanAcceptResponses()); + } + + [Fact] + public void Close_Draft_Throws() + { + var survey = new PublicSurveyFake(); + + Assert.Throws(() => survey.Close()); + } + + [Fact] + public void Reopen_Closed_BecomesPublished() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Closed); + + survey.Reopen(); + + Assert.Equal(PublicSurveyStatus.Published, survey.Status); + } + + [Fact] + public void Reopen_Published_Throws() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + Assert.Throws(() => survey.Reopen()); + } + + [Fact] + public void Reopen_Draft_Throws() + { + var survey = new PublicSurveyFake(); + + Assert.Throws(() => survey.Reopen()); + } + + [Fact] + public void Delete_NotDeleted_SetsDeletedAt() + { + var survey = new PublicSurveyFake(); + + survey.Delete(); + + Assert.NotNull(survey.DeletedAt); + } + + [Fact] + public void Delete_AlreadyDeleted_Throws() + { + var survey = new PublicSurveyFake() + .SetDeletedAt(DateTimeOffset.UtcNow); + + Assert.Throws(() => survey.Delete()); + } + + [Fact] + public void Restore_Deleted_ClearsDeletedAt() + { + var survey = new PublicSurveyFake() + .SetDeletedAt(DateTimeOffset.UtcNow); + + survey.Restore(); + + Assert.Null(survey.DeletedAt); + } + + [Fact] + public void Restore_NotDeleted_Throws() + { + var survey = new PublicSurveyFake(); + + Assert.Throws(() => survey.Restore()); + } + + [Fact] + public void UpdateTitle_Draft_UpdatesTitle() + { + var survey = new PublicSurveyFake(); + + survey.UpdateTitle("New Title"); + + Assert.Equal("New Title", survey.Title); + } + + [Fact] + public void UpdateTitle_Published_Throws() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + Assert.Throws(() => survey.UpdateTitle("New Title")); + } + + [Fact] + public void UpdateTitle_Deleted_Throws() + { + var survey = new PublicSurveyFake() + .SetDeletedAt(DateTimeOffset.UtcNow); + + Assert.Throws(() => survey.UpdateTitle("New Title")); + } + + [Fact] + public void UpdateDescription_Draft_UpdatesDescription() + { + var survey = new PublicSurveyFake(); + + survey.UpdateDescription("New description"); + + Assert.Equal("New description", survey.Description); + } + + [Fact] + public void UpdateDescription_Published_UpdatesDescription() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + survey.UpdateDescription("Updated description"); + + Assert.Equal("Updated description", survey.Description); + } + + [Fact] + public void UpdateDescription_Deleted_Throws() + { + var survey = new PublicSurveyFake() + .SetDeletedAt(DateTimeOffset.UtcNow); + + Assert.Throws(() => survey.UpdateDescription("New desc")); + } + + [Fact] + public void UpdateSlug_Draft_UpdatesSlug() + { + var survey = new PublicSurveyFake(); + + survey.UpdateSlug("new-slug"); + + Assert.Equal("new-slug", survey.Slug); + } + + [Fact] + public void UpdateSlug_Published_Throws() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + Assert.Throws(() => survey.UpdateSlug("new-slug")); + } + + [Fact] + public void CanAcceptResponses_PublishedAndNotDeleted_True() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published); + + Assert.True(survey.CanAcceptResponses()); + } + + [Fact] + public void CanAcceptResponses_PublishedButDeleted_False() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published) + .SetDeletedAt(DateTimeOffset.UtcNow); + + Assert.False(survey.CanAcceptResponses()); + } + + [Fact] + public void CanAcceptResponses_Draft_False() + { + var survey = new PublicSurveyFake(); + + Assert.False(survey.CanAcceptResponses()); + } + + [Fact] + public void Publish_Deleted_Throws() + { + var survey = new PublicSurveyFake() + .SetDeletedAt(DateTimeOffset.UtcNow); + + Assert.Throws(() => survey.Publish()); + } + + [Fact] + public void Close_Deleted_Throws() + { + var survey = new PublicSurveyFake() + .SetStatus(PublicSurveyStatus.Published) + .SetDeletedAt(DateTimeOffset.UtcNow); + + Assert.Throws(() => survey.Close()); + } +} diff --git a/src/Domain/Entities/Surveys/PublicSurvey.cs b/src/Domain/Entities/Surveys/PublicSurvey.cs new file mode 100644 index 00000000..40cab4f3 --- /dev/null +++ b/src/Domain/Entities/Surveys/PublicSurvey.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.Users; +using Domain.Enums; +using Domain.Validation.Exceptions; +using Domain.ValueObjects; + +namespace Domain.Entities.Surveys; + +public class PublicSurvey : HasDatesBase, IHasIdBase, IHasDeletedAt +{ + public const int TitleMaxLength = 500; + public const int DescriptionMaxLength = 2000; + public const int SlugMaxLength = 100; + + public Guid Id { get; protected set; } + + public string Title { get; protected set; } + + public string Description { get; protected set; } + + public string Slug { get; protected set; } + + public long AuthorId { get; protected set; } + + public virtual User Author { get; protected set; } + + public PublicSurveyStatus Status { get; protected set; } + + public DateTimeOffset? PublishedAt { get; protected set; } + + public DateTimeOffset? DeletedAt { get; protected set; } + + public virtual List Questions { get; protected set; } = new (); + + public PublicSurvey( + string title, + string description, + string slug, + long authorId) + { + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentNullException(nameof(title)); + } + + if (title.Length > TitleMaxLength) + { + throw new BadRequestException($"Title must not exceed {TitleMaxLength} characters."); + } + + if (description != null && description.Length > DescriptionMaxLength) + { + throw new BadRequestException($"Description must not exceed {DescriptionMaxLength} characters."); + } + + if (authorId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(authorId)); + } + + Id = Guid.NewGuid(); + Title = title; + Description = description; + Slug = ValidateSlug(slug); + AuthorId = authorId; + Status = PublicSurveyStatus.Draft; + } + + public void Publish() + { + NotDeletedOrFail(); + EnsureDraftOrFail(); + + if (!Questions.Any()) + { + throw new BadRequestException("Survey must have at least one question."); + } + + if (Questions.Any(q => !q.HasValidOptions())) + { + throw new BadRequestException("All questions must have at least 2 options before publishing."); + } + + Status = PublicSurveyStatus.Published; + PublishedAt = DateTimeOffset.UtcNow; + } + + public void Close() + { + NotDeletedOrFail(); + + if (Status != PublicSurveyStatus.Published) + { + throw new BadRequestException("Only published surveys can be closed."); + } + + Status = PublicSurveyStatus.Closed; + } + + public void Reopen() + { + NotDeletedOrFail(); + + if (Status != PublicSurveyStatus.Closed) + { + throw new BadRequestException("Only closed surveys can be reopened."); + } + + Status = PublicSurveyStatus.Published; + } + + public void Delete() + { + if (DeletedAt != null) + { + throw new BadRequestException("Survey is already deleted."); + } + + DeletedAt = DateTimeOffset.UtcNow; + } + + public void Restore() + { + if (DeletedAt == null) + { + throw new BadRequestException("Survey is not deleted."); + } + + DeletedAt = null; + } + + public void UpdateTitle(string title) + { + NotDeletedOrFail(); + EnsureDraftOrFail(); + + if (string.IsNullOrWhiteSpace(title)) + { + throw new ArgumentNullException(nameof(title)); + } + + if (title.Length > TitleMaxLength) + { + throw new BadRequestException($"Title must not exceed {TitleMaxLength} characters."); + } + + Title = title; + } + + public void UpdateDescription(string description) + { + NotDeletedOrFail(); + + if (description != null && description.Length > DescriptionMaxLength) + { + throw new BadRequestException($"Description must not exceed {DescriptionMaxLength} characters."); + } + + Description = description; + } + + public void UpdateSlug(string slug) + { + NotDeletedOrFail(); + EnsureDraftOrFail(); + Slug = ValidateSlug(slug); + } + + public bool CanAcceptResponses() + { + return Status == PublicSurveyStatus.Published && DeletedAt == null; + } + + public bool IsDraft() + { + return Status == PublicSurveyStatus.Draft; + } + + public bool IsPublished() + { + return Status == PublicSurveyStatus.Published; + } + + private void EnsureDraftOrFail() + { + if (Status != PublicSurveyStatus.Draft) + { + throw new BadRequestException("This operation is only allowed for draft surveys."); + } + } + + private void NotDeletedOrFail() + { + if (DeletedAt != null) + { + throw new BadRequestException("Cannot modify a deleted survey."); + } + } + + private static string ValidateSlug(string slug) + { + if (string.IsNullOrWhiteSpace(slug)) + { + throw new ArgumentNullException(nameof(slug)); + } + + var result = new KebabCaseSlug(slug).ToString(); + + if (result.Length > SlugMaxLength) + { + throw new BadRequestException($"Slug must not exceed {SlugMaxLength} characters."); + } + + return result; + } + + protected PublicSurvey() + { + } +} diff --git a/src/Domain/Entities/Surveys/PublicSurveyOption.cs b/src/Domain/Entities/Surveys/PublicSurveyOption.cs new file mode 100644 index 00000000..65163d2b --- /dev/null +++ b/src/Domain/Entities/Surveys/PublicSurveyOption.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using Domain.Validation.Exceptions; + +namespace Domain.Entities.Surveys; + +public class PublicSurveyOption : HasDatesBase, IHasIdBase +{ + public const int TextMaxLength = 200; + + public Guid Id { get; protected set; } + + public Guid QuestionId { get; protected set; } + + public virtual PublicSurveyQuestion Question { get; protected set; } + + public string Text { get; protected set; } + + public int Order { get; protected set; } + + public virtual List ResponseOptions { get; protected set; } = new (); + + public PublicSurveyOption( + string text, + int order, + PublicSurveyQuestion question) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentNullException(nameof(text)); + } + + if (text.Length > TextMaxLength) + { + throw new BadRequestException($"Option text must not exceed {TextMaxLength} characters."); + } + + if (order < 0) + { + throw new ArgumentOutOfRangeException(nameof(order)); + } + + Id = Guid.NewGuid(); + Text = text; + Order = order; + QuestionId = question?.Id ?? throw new ArgumentNullException(nameof(question)); + Question = question; + } + + public void UpdateText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentNullException(nameof(text)); + } + + if (text.Length > TextMaxLength) + { + throw new BadRequestException($"Option text must not exceed {TextMaxLength} characters."); + } + + Text = text; + } + + public void UpdateOrder(int order) + { + if (order < 0) + { + throw new ArgumentOutOfRangeException(nameof(order)); + } + + Order = order; + } + + protected PublicSurveyOption() + { + } +} diff --git a/src/Domain/Entities/Surveys/PublicSurveyQuestion.cs b/src/Domain/Entities/Surveys/PublicSurveyQuestion.cs new file mode 100644 index 00000000..7646d45b --- /dev/null +++ b/src/Domain/Entities/Surveys/PublicSurveyQuestion.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Validation.Exceptions; + +namespace Domain.Entities.Surveys; + +public class PublicSurveyQuestion : HasDatesBase, IHasIdBase +{ + public const int TextMaxLength = 500; + public const int MinOptions = 2; + public const int MaxOptions = 10; + + public Guid Id { get; protected set; } + + public Guid PublicSurveyId { get; protected set; } + + public virtual PublicSurvey PublicSurvey { get; protected set; } + + public string Text { get; protected set; } + + public int Order { get; protected set; } + + public bool AllowMultipleChoices { get; protected set; } + + public virtual List Options { get; protected set; } = new (); + + public virtual List Responses { get; protected set; } = new (); + + public PublicSurveyQuestion( + string text, + int order, + PublicSurvey survey, + bool allowMultipleChoices = false) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentNullException(nameof(text)); + } + + if (text.Length > TextMaxLength) + { + throw new BadRequestException($"Question text must not exceed {TextMaxLength} characters."); + } + + if (order < 0) + { + throw new ArgumentOutOfRangeException(nameof(order)); + } + + Id = Guid.NewGuid(); + Text = text; + Order = order; + AllowMultipleChoices = allowMultipleChoices; + PublicSurveyId = survey?.Id ?? throw new ArgumentNullException(nameof(survey)); + PublicSurvey = survey; + } + + public PublicSurveyOption AddOption(string text, int order) + { + if (Options.Count >= MaxOptions) + { + throw new BadRequestException($"A question cannot have more than {MaxOptions} options."); + } + + var option = new PublicSurveyOption(text, order, this); + Options.Add(option); + return option; + } + + public void RemoveOption(Guid optionId) + { + var option = Options.FirstOrDefault(x => x.Id == optionId); + if (option == null) + { + throw new NotFoundException($"Option {optionId} not found."); + } + + if (Options.Count <= MinOptions) + { + throw new BadRequestException($"A question must have at least {MinOptions} options."); + } + + Options.Remove(option); + } + + public bool HasValidOptions() + { + return Options.Count >= MinOptions; + } + + public void UpdateText(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ArgumentNullException(nameof(text)); + } + + if (text.Length > TextMaxLength) + { + throw new BadRequestException($"Question text must not exceed {TextMaxLength} characters."); + } + + Text = text; + } + + public void UpdateOrder(int order) + { + if (order < 0) + { + throw new ArgumentOutOfRangeException(nameof(order)); + } + + Order = order; + } + + public void SetAllowMultipleChoices(bool allow) + { + AllowMultipleChoices = allow; + } + + protected PublicSurveyQuestion() + { + } +} diff --git a/src/Domain/Entities/Surveys/PublicSurveyResponse.cs b/src/Domain/Entities/Surveys/PublicSurveyResponse.cs new file mode 100644 index 00000000..ab1a3f3b --- /dev/null +++ b/src/Domain/Entities/Surveys/PublicSurveyResponse.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Validation.Exceptions; + +namespace Domain.Entities.Surveys; + +public class PublicSurveyResponse : HasDatesBase, IHasIdBase +{ + public Guid Id { get; protected set; } + + public Guid QuestionId { get; protected set; } + + public virtual PublicSurveyQuestion Question { get; protected set; } + + public long UserId { get; protected set; } + + public virtual List SelectedOptions { get; protected set; } = new (); + + public PublicSurveyResponse( + PublicSurveyQuestion question, + long userId) + { + if (question == null) + { + throw new ArgumentNullException(nameof(question)); + } + + if (userId <= 0) + { + throw new ArgumentOutOfRangeException(nameof(userId)); + } + + Id = Guid.NewGuid(); + QuestionId = question.Id; + Question = question; + UserId = userId; + } + + public void AddSelectedOption(PublicSurveyOption option) + { + if (option == null) + { + throw new ArgumentNullException(nameof(option)); + } + + if (option.QuestionId != QuestionId) + { + throw new BadRequestException("Option does not belong to the same question as this response."); + } + + if (SelectedOptions.Any(x => x.OptionId == option.Id)) + { + throw new BadRequestException("This option has already been selected."); + } + + SelectedOptions.Add(new PublicSurveyResponseOption(Id, option.Id)); + } + + protected PublicSurveyResponse() + { + } +} diff --git a/src/Domain/Entities/Surveys/PublicSurveyResponseOption.cs b/src/Domain/Entities/Surveys/PublicSurveyResponseOption.cs new file mode 100644 index 00000000..de6cb716 --- /dev/null +++ b/src/Domain/Entities/Surveys/PublicSurveyResponseOption.cs @@ -0,0 +1,26 @@ +using System; + +namespace Domain.Entities.Surveys; + +public class PublicSurveyResponseOption +{ + public Guid ResponseId { get; protected set; } + + public virtual PublicSurveyResponse Response { get; protected set; } + + public Guid OptionId { get; protected set; } + + public virtual PublicSurveyOption Option { get; protected set; } + + public PublicSurveyResponseOption( + Guid responseId, + Guid optionId) + { + ResponseId = responseId; + OptionId = optionId; + } + + protected PublicSurveyResponseOption() + { + } +} diff --git a/src/Domain/Enums/PublicSurveyStatus.cs b/src/Domain/Enums/PublicSurveyStatus.cs new file mode 100644 index 00000000..bd998842 --- /dev/null +++ b/src/Domain/Enums/PublicSurveyStatus.cs @@ -0,0 +1,9 @@ +namespace Domain.Enums; + +public enum PublicSurveyStatus +{ + Undefined = 0, + Draft = 1, + Published = 2, + Closed = 3, +} diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs new file mode 100644 index 00000000..ad4941de --- /dev/null +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs @@ -0,0 +1,45 @@ +using Domain.Entities.Surveys; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config.Surveys; + +public class PublicSurveyEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Title) + .HasMaxLength(PublicSurvey.TitleMaxLength) + .IsRequired(); + + builder + .Property(x => x.Description) + .HasMaxLength(PublicSurvey.DescriptionMaxLength); + + builder + .Property(x => x.Slug) + .HasMaxLength(PublicSurvey.SlugMaxLength) + .IsRequired(); + + builder + .HasIndex(x => x.Slug) + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + builder + .HasOne(x => x.Author) + .WithMany() + .HasForeignKey(x => x.AuthorId) + .OnDelete(DeleteBehavior.Restrict); + + builder + .HasIndex(x => x.AuthorId); + + builder + .HasIndex(x => x.Status); + } +} diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs new file mode 100644 index 00000000..d58ec446 --- /dev/null +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs @@ -0,0 +1,29 @@ +using Domain.Entities.Surveys; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config.Surveys; + +public class PublicSurveyOptionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Text) + .HasMaxLength(PublicSurveyOption.TextMaxLength) + .IsRequired(); + + builder + .HasIndex(x => new { x.QuestionId, x.Order }) + .IsUnique(); + + builder + .HasOne(x => x.Question) + .WithMany(x => x.Options) + .HasForeignKey(x => x.QuestionId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs new file mode 100644 index 00000000..edb6afeb --- /dev/null +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs @@ -0,0 +1,29 @@ +using Domain.Entities.Surveys; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config.Surveys; + +public class PublicSurveyQuestionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Text) + .HasMaxLength(PublicSurveyQuestion.TextMaxLength) + .IsRequired(); + + builder + .HasIndex(x => new { x.PublicSurveyId, x.Order }) + .IsUnique(); + + builder + .HasOne(x => x.PublicSurvey) + .WithMany(x => x.Questions) + .HasForeignKey(x => x.PublicSurveyId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs new file mode 100644 index 00000000..e4f07210 --- /dev/null +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs @@ -0,0 +1,24 @@ +using Domain.Entities.Surveys; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config.Surveys; + +public class PublicSurveyResponseEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder + .HasIndex(x => new { x.QuestionId, x.UserId }) + .IsUnique(); + + builder + .HasOne(x => x.Question) + .WithMany(x => x.Responses) + .HasForeignKey(x => x.QuestionId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseOptionEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseOptionEntityConfiguration.cs new file mode 100644 index 00000000..f6227ab2 --- /dev/null +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseOptionEntityConfiguration.cs @@ -0,0 +1,27 @@ +using Domain.Entities.Surveys; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config.Surveys; + +public class PublicSurveyResponseOptionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder + .HasKey(x => new { x.ResponseId, x.OptionId }); + + builder + .HasOne(x => x.Response) + .WithMany(x => x.SelectedOptions) + .HasForeignKey(x => x.ResponseId) + .OnDelete(DeleteBehavior.Cascade); + + builder + .HasOne(x => x.Option) + .WithMany(x => x.ResponseOptions) + .HasForeignKey(x => x.OptionId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Infrastructure/Database/DatabaseContext.cs b/src/Infrastructure/Database/DatabaseContext.cs index 4be6cd9c..b97d12bd 100644 --- a/src/Infrastructure/Database/DatabaseContext.cs +++ b/src/Infrastructure/Database/DatabaseContext.cs @@ -13,6 +13,7 @@ using Domain.Entities.StatData; using Domain.Entities.StatData.CompanyReviews; using Domain.Entities.StatData.Salary; +using Domain.Entities.Surveys; using Domain.Entities.Telegram; using Domain.Entities.Users; using Domain.Validation; @@ -106,6 +107,16 @@ public DatabaseContext(DbContextOptions options) public DbSet CurrencyCollections { get; set; } + public DbSet PublicSurveys { get; set; } + + public DbSet PublicSurveyQuestions { get; set; } + + public DbSet PublicSurveyOptions { get; set; } + + public DbSet PublicSurveyResponses { get; set; } + + public DbSet PublicSurveyResponseOptions { get; set; } + public async Task SaveAsync( IReadOnlyCollection entities) where TEntity : class, IBaseModel diff --git a/src/TestUtils/Fakes/PublicSurveyFake.cs b/src/TestUtils/Fakes/PublicSurveyFake.cs new file mode 100644 index 00000000..34743649 --- /dev/null +++ b/src/TestUtils/Fakes/PublicSurveyFake.cs @@ -0,0 +1,38 @@ +using System; +using Domain.Entities.Surveys; +using Domain.Enums; + +namespace TestUtils.Fakes; + +public class PublicSurveyFake : PublicSurvey +{ + public PublicSurveyFake( + long authorId = 1, + string title = null, + string slug = null) + : base( + title ?? "Test Survey " + Guid.NewGuid().ToString("N")[..8], + "Test survey description", + slug ?? "test-survey-" + Guid.NewGuid().ToString("N")[..8], + authorId) + { + } + + public PublicSurveyFake SetStatus(PublicSurveyStatus status) + { + Status = status; + return this; + } + + public PublicSurveyFake SetPublishedAt(DateTimeOffset publishedAt) + { + PublishedAt = publishedAt; + return this; + } + + public PublicSurveyFake SetDeletedAt(DateTimeOffset deletedAt) + { + DeletedAt = deletedAt; + return this; + } +} diff --git a/src/TestUtils/Fakes/PublicSurveyOptionFake.cs b/src/TestUtils/Fakes/PublicSurveyOptionFake.cs new file mode 100644 index 00000000..56c2317e --- /dev/null +++ b/src/TestUtils/Fakes/PublicSurveyOptionFake.cs @@ -0,0 +1,17 @@ +using Domain.Entities.Surveys; + +namespace TestUtils.Fakes; + +public class PublicSurveyOptionFake : PublicSurveyOption +{ + public PublicSurveyOptionFake( + PublicSurveyQuestion question, + string text = null, + int order = 0) + : base( + text ?? "Test option", + order, + question) + { + } +} diff --git a/src/TestUtils/Fakes/PublicSurveyQuestionFake.cs b/src/TestUtils/Fakes/PublicSurveyQuestionFake.cs new file mode 100644 index 00000000..df5ae50a --- /dev/null +++ b/src/TestUtils/Fakes/PublicSurveyQuestionFake.cs @@ -0,0 +1,19 @@ +using Domain.Entities.Surveys; + +namespace TestUtils.Fakes; + +public class PublicSurveyQuestionFake : PublicSurveyQuestion +{ + public PublicSurveyQuestionFake( + PublicSurvey survey, + string text = null, + int order = 0, + bool allowMultipleChoices = false) + : base( + text ?? "Test question?", + order, + survey, + allowMultipleChoices) + { + } +} From b5710e75789d86f37e1998c5e9933030eace7045 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Thu, 12 Feb 2026 20:18:41 +0500 Subject: [PATCH 2/4] Phase 2 --- .../PublicSurveyEntityConfiguration.cs | 4 + .../PublicSurveyOptionEntityConfiguration.cs | 4 + ...PublicSurveyQuestionEntityConfiguration.cs | 4 + ...PublicSurveyResponseEntityConfiguration.cs | 4 + .../ClosePublicSurveyHandlerTests.cs | 67 ++++++ .../CreatePublicSurveyHandlerTests.cs | 99 +++++++++ .../DeletePublicSurveyHandlerTests.cs | 52 +++++ .../GetMyPublicSurveysHandlerTests.cs | 111 ++++++++++ .../GetPublicSurveyBySlugHandlerTests.cs | 142 ++++++++++++ .../GetPublicSurveyResultsHandlerTests.cs | 149 +++++++++++++ .../PublishPublicSurveyHandlerTests.cs | 75 +++++++ .../ReopenPublicSurveyHandlerTests.cs | 71 ++++++ .../RestorePublicSurveyHandlerTests.cs | 57 +++++ .../SubmitPublicSurveyResponseHandlerTests.cs | 206 ++++++++++++++++++ .../UpdatePublicSurveyHandlerTests.cs | 164 ++++++++++++++ .../ClosePublicSurveyHandler.cs | 47 ++++ .../CreatePublicSurveyHandler.cs | 58 +++++ .../CreatePublicSurveyRequest.cs | 29 +++ .../DeletePublicSurveyHandler.cs | 44 ++++ .../PublicSurveys/Dtos/MySurveyListItemDto.cs | 46 ++++ .../PublicSurveys/Dtos/PublicSurveyDto.cs | 58 +++++ .../Dtos/PublicSurveyOptionDto.cs | 24 ++ .../Dtos/PublicSurveyQuestionDto.cs | 40 ++++ .../Dtos/PublicSurveyResultsDto.cs | 22 ++ .../GetMyPublicSurveysHandler.cs | 57 +++++ .../GetMyPublicSurveysQuery.cs | 10 + .../GetPublicSurveyBySlugHandler.cs | 71 ++++++ .../GetPublicSurveyResultsHandler.cs | 87 ++++++++ .../PublishPublicSurveyHandler.cs | 48 ++++ .../ReopenPublicSurveyHandler.cs | 47 ++++ .../RestorePublicSurveyHandler.cs | 47 ++++ .../SubmitPublicSurveyResponseCommand.cs | 16 ++ .../SubmitPublicSurveyResponseHandler.cs | 86 ++++++++ .../SubmitPublicSurveyResponseRequest.cs | 12 + .../UpdatePublicSurveyHandler.cs | 104 +++++++++ .../UpdatePublicSurveyRequest.cs | 49 +++++ 36 files changed, 2211 insertions(+) create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandlerTests.cs create mode 100644 src/Web.Api.Tests/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandlerTests.cs create mode 100644 src/Web.Api/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyRequest.cs create mode 100644 src/Web.Api/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/Dtos/MySurveyListItemDto.cs create mode 100644 src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyDto.cs create mode 100644 src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyOptionDto.cs create mode 100644 src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyQuestionDto.cs create mode 100644 src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyResultsDto.cs create mode 100644 src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysQuery.cs create mode 100644 src/Web.Api/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseCommand.cs create mode 100644 src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseRequest.cs create mode 100644 src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandler.cs create mode 100644 src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyRequest.cs diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs index ad4941de..82d08747 100644 --- a/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyEntityConfiguration.cs @@ -11,6 +11,10 @@ public void Configure( { builder.HasKey(x => x.Id); + builder + .Property(x => x.Id) + .ValueGeneratedNever(); + builder .Property(x => x.Title) .HasMaxLength(PublicSurvey.TitleMaxLength) diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs index d58ec446..3a471a92 100644 --- a/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyOptionEntityConfiguration.cs @@ -11,6 +11,10 @@ public void Configure( { builder.HasKey(x => x.Id); + builder + .Property(x => x.Id) + .ValueGeneratedNever(); + builder .Property(x => x.Text) .HasMaxLength(PublicSurveyOption.TextMaxLength) diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs index edb6afeb..93aaa173 100644 --- a/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyQuestionEntityConfiguration.cs @@ -11,6 +11,10 @@ public void Configure( { builder.HasKey(x => x.Id); + builder + .Property(x => x.Id) + .ValueGeneratedNever(); + builder .Property(x => x.Text) .HasMaxLength(PublicSurveyQuestion.TextMaxLength) diff --git a/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs b/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs index e4f07210..cab5499f 100644 --- a/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs +++ b/src/Infrastructure/Database/Config/Surveys/PublicSurveyResponseEntityConfiguration.cs @@ -11,6 +11,10 @@ public void Configure( { builder.HasKey(x => x.Id); + builder + .Property(x => x.Id) + .ValueGeneratedNever(); + builder .HasIndex(x => new { x.QuestionId, x.UserId }) .IsUnique(); diff --git a/src/Web.Api.Tests/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandlerTests.cs new file mode 100644 index 00000000..cc92fc1d --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandlerTests.cs @@ -0,0 +1,67 @@ +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.ClosePublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.ClosePublicSurvey; + +public class ClosePublicSurveyHandlerTests +{ + [Fact] + public async Task Close_Published_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreatePublishedSurvey(user, context); + + context.ChangeTracker.Clear(); + + var result = await new ClosePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default); + + Assert.Equal(PublicSurveyStatus.Closed, result.Status); + } + + [Fact] + public async Task Close_Draft_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(user, context); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new ClosePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default)); + } + + private static PublicSurvey CreateDraftSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = new PublicSurvey("Test", "desc", "slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + return survey; + } + + private static PublicSurvey CreatePublishedSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = CreateDraftSurvey(user, context); + survey.Publish(); + context.SaveChanges(); + return survey; + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandlerTests.cs new file mode 100644 index 00000000..4934245b --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandlerTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.CreatePublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.CreatePublicSurvey; + +public class CreatePublicSurveyHandlerTests +{ + [Fact] + public async Task Create_ValidData_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var request = new CreatePublicSurveyRequest + { + Title = "Test Survey", + Description = "A test survey", + Slug = "test-survey", + Question = "What is your favorite color?", + AllowMultipleChoices = false, + Options = new List { "Red", "Blue", "Green" }, + }; + + var result = await new CreatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(request, default); + + Assert.NotNull(result); + Assert.Equal("Test Survey", result.Title); + Assert.Equal("A test survey", result.Description); + Assert.Equal("test-survey", result.Slug); + Assert.Equal(PublicSurveyStatus.Draft, result.Status); + Assert.True(result.IsAuthor); + Assert.Null(result.PublishedAt); + + Assert.NotNull(result.Question); + Assert.Equal("What is your favorite color?", result.Question.Text); + Assert.False(result.Question.AllowMultipleChoices); + Assert.Equal(3, result.Question.Options.Count); + Assert.Equal("Red", result.Question.Options[0].Text); + Assert.Equal("Blue", result.Question.Options[1].Text); + Assert.Equal("Green", result.Question.Options[2].Text); + + Assert.Equal(1, context.PublicSurveys.Count()); + Assert.Equal(1, context.PublicSurveyQuestions.Count()); + Assert.Equal(3, context.PublicSurveyOptions.Count()); + } + + [Fact] + public async Task Create_WithMultipleChoices_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var request = new CreatePublicSurveyRequest + { + Title = "Multi Survey", + Slug = "multi-survey", + Question = "Select all that apply", + AllowMultipleChoices = true, + Options = new List { "A", "B" }, + }; + + var result = await new CreatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(request, default); + + Assert.True(result.Question.AllowMultipleChoices); + Assert.Equal(2, result.Question.Options.Count); + } + + [Fact] + public async Task Create_TooFewOptions_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var request = new CreatePublicSurveyRequest + { + Title = "Bad Survey", + Slug = "bad-survey", + Question = "Only one option", + Options = new List { "Only" }, + }; + + await Assert.ThrowsAnyAsync(() => + new CreatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(request, default)); + + Assert.Equal(0, context.PublicSurveys.Count()); + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandlerTests.cs new file mode 100644 index 00000000..a9c24d14 --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandlerTests.cs @@ -0,0 +1,52 @@ +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.DeletePublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.DeletePublicSurvey; + +public class DeletePublicSurveyHandlerTests +{ + [Fact] + public async Task Delete_ExistingSurvey_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "slug", user.Id); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await new DeletePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default); + + var deleted = context.PublicSurveys.First(s => s.Id == survey.Id); + Assert.NotNull(deleted.DeletedAt); + } + + [Fact] + public async Task Delete_NotAuthor_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var other = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "slug", author.Id); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new DeletePublicSurveyHandler(context, new FakeAuth(other)) + .Handle(survey.Id, default)); + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandlerTests.cs new file mode 100644 index 00000000..f3a6601f --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandlerTests.cs @@ -0,0 +1,111 @@ +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.GetMyPublicSurveys; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.GetMyPublicSurveys; + +public class GetMyPublicSurveysHandlerTests +{ + [Fact] + public async Task GetMy_ReturnsOnlyOwnSurveys() + { + await using var context = new InMemoryDatabaseContext(); + var user1 = await new UserFake(Role.Interviewer).PleaseAsync(context); + var user2 = await new UserFake(Role.Interviewer).PleaseAsync(context); + + context.PublicSurveys.Add(new PublicSurvey("Survey 1", null, "slug-1", user1.Id)); + context.PublicSurveys.Add(new PublicSurvey("Survey 2", null, "slug-2", user1.Id)); + context.PublicSurveys.Add(new PublicSurvey("Other", null, "slug-3", user2.Id)); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var result = await new GetMyPublicSurveysHandler(context, new FakeAuth(user1)) + .Handle(new GetMyPublicSurveysQuery(), default); + + Assert.Equal(2, result.Count); + Assert.All(result, s => Assert.Contains("Survey", s.Title)); + } + + [Fact] + public async Task GetMy_ExcludesDeletedByDefault() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey1 = new PublicSurvey("Active", null, "slug-1", user.Id); + var survey2 = new PublicSurvey("Deleted", null, "slug-2", user.Id); + context.PublicSurveys.Add(survey1); + context.PublicSurveys.Add(survey2); + context.SaveChanges(); + + survey2.Delete(); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var result = await new GetMyPublicSurveysHandler(context, new FakeAuth(user)) + .Handle(new GetMyPublicSurveysQuery { IncludeDeleted = false }, default); + + Assert.Single(result); + Assert.Equal("Active", result.First().Title); + } + + [Fact] + public async Task GetMy_IncludesDeletedWhenRequested() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey1 = new PublicSurvey("Active", null, "slug-1", user.Id); + var survey2 = new PublicSurvey("Deleted", null, "slug-2", user.Id); + context.PublicSurveys.Add(survey1); + context.PublicSurveys.Add(survey2); + context.SaveChanges(); + + survey2.Delete(); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var result = await new GetMyPublicSurveysHandler(context, new FakeAuth(user)) + .Handle(new GetMyPublicSurveysQuery { IncludeDeleted = true }, default); + + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetMy_FilterByStatus() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var draft = new PublicSurvey("Draft", null, "slug-1", user.Id); + context.PublicSurveys.Add(draft); + + var published = new PublicSurvey("Published", null, "slug-2", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, published); + published.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(published); + context.SaveChanges(); + + published.Publish(); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var result = await new GetMyPublicSurveysHandler(context, new FakeAuth(user)) + .Handle(new GetMyPublicSurveysQuery { Status = PublicSurveyStatus.Published }, default); + + Assert.Single(result); + Assert.Equal("Published", result.First().Title); + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandlerTests.cs new file mode 100644 index 00000000..8be50cc6 --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandlerTests.cs @@ -0,0 +1,142 @@ +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.GetPublicSurveyBySlug; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.GetPublicSurveyBySlug; + +public class GetPublicSurveyBySlugHandlerTests +{ + [Fact] + public async Task Get_PublishedSurvey_NonAuthor_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var viewer = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreatePublishedSurvey(author, context); + + context.ChangeTracker.Clear(); + + var result = await new GetPublicSurveyBySlugHandler(context, new FakeAuth(viewer)) + .Handle("test-slug", default); + + Assert.NotNull(result); + Assert.Equal("Test", result.Title); + Assert.False(result.IsAuthor); + Assert.False(result.Question.HasUserResponded); + } + + [Fact] + public async Task Get_PublishedSurvey_Author_IsAuthorTrue() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreatePublishedSurvey(author, context); + + context.ChangeTracker.Clear(); + + var result = await new GetPublicSurveyBySlugHandler(context, new FakeAuth(author)) + .Handle("test-slug", default); + + Assert.True(result.IsAuthor); + } + + [Fact] + public async Task Get_DraftSurvey_NonAuthor_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var viewer = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "test-slug", author.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new GetPublicSurveyBySlugHandler(context, new FakeAuth(viewer)) + .Handle("test-slug", default)); + } + + [Fact] + public async Task Get_DraftSurvey_Author_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "test-slug", author.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var result = await new GetPublicSurveyBySlugHandler(context, new FakeAuth(author)) + .Handle("test-slug", default); + + Assert.NotNull(result); + Assert.True(result.IsAuthor); + Assert.Equal(PublicSurveyStatus.Draft, result.Status); + } + + [Fact] + public async Task Get_DeletedSurvey_NonAuthor_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var viewer = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreatePublishedSurvey(author, context); + + survey.Delete(); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new GetPublicSurveyBySlugHandler(context, new FakeAuth(viewer)) + .Handle("test-slug", default)); + } + + [Fact] + public async Task Get_NonExistentSlug_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + await Assert.ThrowsAsync(() => + new GetPublicSurveyBySlugHandler(context, new FakeAuth(user)) + .Handle("non-existent", default)); + } + + private static PublicSurvey CreatePublishedSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = new PublicSurvey("Test", "desc", "test-slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + survey.Publish(); + context.SaveChanges(); + + return survey; + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandlerTests.cs new file mode 100644 index 00000000..e3aaaf04 --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandlerTests.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.GetPublicSurveyResults; +using Web.Api.Features.PublicSurveys.SubmitPublicSurveyResponse; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.GetPublicSurveyResults; + +public class GetPublicSurveyResultsHandlerTests +{ + [Fact] + public async Task GetResults_AfterResponding_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + var optionA = question.Options.First(o => o.Text == "A"); + + await SubmitResponse(context, respondent, "test-slug", new List { optionA.Id }); + context.ChangeTracker.Clear(); + + var result = await new GetPublicSurveyResultsHandler(context, new FakeAuth(respondent)) + .Handle("test-slug", default); + + Assert.Equal(1, result.TotalResponses); + Assert.Equal(2, result.Options.Count); + + var optionAResult = result.Options.First(o => o.Text == "A"); + Assert.Equal(1, optionAResult.ResponseCount); + Assert.Equal(100.0m, optionAResult.Percentage); + + var optionBResult = result.Options.First(o => o.Text == "B"); + Assert.Equal(0, optionBResult.ResponseCount); + Assert.Equal(0m, optionBResult.Percentage); + } + + [Fact] + public async Task GetResults_NotResponded_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var viewer = await new UserFake(Role.Interviewer).PleaseAsync(context); + CreatePublishedSurvey(author, context); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new GetPublicSurveyResultsHandler(context, new FakeAuth(viewer)) + .Handle("test-slug", default)); + } + + [Fact] + public async Task GetResults_DraftSurvey_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "test-slug", author.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new GetPublicSurveyResultsHandler(context, new FakeAuth(author)) + .Handle("test-slug", default)); + } + + [Fact] + public async Task GetResults_MultipleRespondents_CorrectPercentages() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var user1 = await new UserFake(Role.Interviewer).PleaseAsync(context); + var user2 = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + var optionA = question.Options.First(o => o.Text == "A"); + var optionB = question.Options.First(o => o.Text == "B"); + + await SubmitResponse(context, user1, "test-slug", new List { optionA.Id }); + await SubmitResponse(context, user2, "test-slug", new List { optionA.Id }); + await SubmitResponse(context, author, "test-slug", new List { optionB.Id }); + + context.ChangeTracker.Clear(); + + var result = await new GetPublicSurveyResultsHandler(context, new FakeAuth(user1)) + .Handle("test-slug", default); + + Assert.Equal(3, result.TotalResponses); + + var aResult = result.Options.First(o => o.Text == "A"); + Assert.Equal(2, aResult.ResponseCount); + Assert.Equal(66.7m, aResult.Percentage); + + var bResult = result.Options.First(o => o.Text == "B"); + Assert.Equal(1, bResult.ResponseCount); + Assert.Equal(33.3m, bResult.Percentage); + } + + private static async Task SubmitResponse( + InMemoryDatabaseContext context, + Domain.Entities.Users.User user, + string slug, + List optionIds) + { + var command = new SubmitPublicSurveyResponseCommand( + slug, + new SubmitPublicSurveyResponseRequest + { + OptionIds = optionIds, + }); + + await new SubmitPublicSurveyResponseHandler(context, new FakeAuth(user)) + .Handle(command, default); + } + + private static (PublicSurvey Survey, PublicSurveyQuestion Question) CreatePublishedSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = new PublicSurvey("Test", "desc", "test-slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + survey.Publish(); + context.SaveChanges(); + + return (survey, question); + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandlerTests.cs new file mode 100644 index 00000000..9580599d --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandlerTests.cs @@ -0,0 +1,75 @@ +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.PublishPublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.PublishPublicSurvey; + +public class PublishPublicSurveyHandlerTests +{ + [Fact] + public async Task Publish_DraftWithValidQuestions_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(user, context); + + context.ChangeTracker.Clear(); + + var result = await new PublishPublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default); + + Assert.Equal(PublicSurveyStatus.Published, result.Status); + Assert.NotNull(result.PublishedAt); + } + + [Fact] + public async Task Publish_AlreadyPublished_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(user, context); + survey.Publish(); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new PublishPublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default)); + } + + [Fact] + public async Task Publish_NotAuthor_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var other = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(author, context); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new PublishPublicSurveyHandler(context, new FakeAuth(other)) + .Handle(survey.Id, default)); + } + + private static PublicSurvey CreateDraftSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = new PublicSurvey("Test", "desc", "test-slug", user.Id); + var question = new PublicSurveyQuestion("Question?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + return survey; + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandlerTests.cs new file mode 100644 index 00000000..34c9c7b7 --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandlerTests.cs @@ -0,0 +1,71 @@ +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.ReopenPublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.ReopenPublicSurvey; + +public class ReopenPublicSurveyHandlerTests +{ + [Fact] + public async Task Reopen_Closed_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateClosedSurvey(user, context); + + context.ChangeTracker.Clear(); + + var result = await new ReopenPublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default); + + Assert.Equal(PublicSurveyStatus.Published, result.Status); + } + + [Fact] + public async Task Reopen_Draft_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new ReopenPublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default)); + } + + private static PublicSurvey CreateClosedSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = new PublicSurvey("Test", "desc", "slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + survey.Publish(); + context.SaveChanges(); + + survey.Close(); + context.SaveChanges(); + + return survey; + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandlerTests.cs new file mode 100644 index 00000000..079183e3 --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandlerTests.cs @@ -0,0 +1,57 @@ +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.RestorePublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.RestorePublicSurvey; + +public class RestorePublicSurveyHandlerTests +{ + [Fact] + public async Task Restore_DeletedSurvey_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + survey.Delete(); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + var result = await new RestorePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default); + + Assert.Null(result.DeletedAt); + } + + [Fact] + public async Task Restore_NotDeleted_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + + var survey = new PublicSurvey("Test", "desc", "slug", user.Id); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new RestorePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(survey.Id, default)); + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandlerTests.cs new file mode 100644 index 00000000..29395c14 --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandlerTests.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.SubmitPublicSurveyResponse; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.SubmitPublicSurveyResponse; + +public class SubmitPublicSurveyResponseHandlerTests +{ + [Fact] + public async Task Submit_ValidSingleChoice_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + var optionA = question.Options.First(o => o.Text == "A"); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = new List { optionA.Id }, + }); + + context.ChangeTracker.Clear(); + + await new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default); + + Assert.Equal(1, context.PublicSurveyResponses.Count()); + Assert.Equal(1, context.PublicSurveyResponseOptions.Count()); + } + + [Fact] + public async Task Submit_MultipleChoices_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context, allowMultiple: true); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = question.Options.Select(o => o.Id).ToList(), + }); + + context.ChangeTracker.Clear(); + + await new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default); + + Assert.Equal(1, context.PublicSurveyResponses.Count()); + Assert.Equal(2, context.PublicSurveyResponseOptions.Count()); + } + + [Fact] + public async Task Submit_MultipleChoicesNotAllowed_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = question.Options.Select(o => o.Id).ToList(), + }); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default)); + } + + [Fact] + public async Task Submit_DuplicateResponse_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + var optionA = question.Options.First(); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = new List { optionA.Id }, + }); + + context.ChangeTracker.Clear(); + + await new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default)); + } + + [Fact] + public async Task Submit_ClosedSurvey_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + survey.Close(); + context.SaveChanges(); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = new List { question.Options.First().Id }, + }); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default)); + } + + [Fact] + public async Task Submit_InvalidOptionId_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var respondent = await new UserFake(Role.Interviewer).PleaseAsync(context); + CreatePublishedSurvey(author, context); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = new List { Guid.NewGuid() }, + }); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new SubmitPublicSurveyResponseHandler(context, new FakeAuth(respondent)) + .Handle(command, default)); + } + + [Fact] + public async Task Submit_AuthorCanRespondToo_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var (survey, question) = CreatePublishedSurvey(author, context); + + var command = new SubmitPublicSurveyResponseCommand( + "test-slug", + new SubmitPublicSurveyResponseRequest + { + OptionIds = new List { question.Options.First().Id }, + }); + + context.ChangeTracker.Clear(); + + await new SubmitPublicSurveyResponseHandler(context, new FakeAuth(author)) + .Handle(command, default); + + Assert.Equal(1, context.PublicSurveyResponses.Count()); + } + + private static (PublicSurvey Survey, PublicSurveyQuestion Question) CreatePublishedSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context, + bool allowMultiple = false) + { + var survey = new PublicSurvey("Test", "desc", "test-slug", user.Id); + var question = new PublicSurveyQuestion("Q?", 0, survey, allowMultiple); + survey.Questions.Add(question); + question.AddOption("A", 0); + question.AddOption("B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + + survey.Publish(); + context.SaveChanges(); + + return (survey, question); + } +} diff --git a/src/Web.Api.Tests/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandlerTests.cs b/src/Web.Api.Tests/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandlerTests.cs new file mode 100644 index 00000000..51053dbf --- /dev/null +++ b/src/Web.Api.Tests/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandlerTests.cs @@ -0,0 +1,164 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Enums; +using Domain.Validation.Exceptions; +using Infrastructure.Database; +using TestUtils.Auth; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.PublicSurveys.UpdatePublicSurvey; +using Xunit; + +namespace Web.Api.Tests.Features.PublicSurveys.UpdatePublicSurvey; + +public class UpdatePublicSurveyHandlerTests +{ + [Fact] + public async Task Update_DraftSurvey_TextFields_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(user, context); + + var command = new UpdatePublicSurveyCommand( + survey.Id, + new UpdatePublicSurveyRequest + { + Title = "Updated Title", + Description = "Updated desc", + Slug = "updated-slug", + Question = "Updated question?", + AllowMultipleChoices = true, + }); + + var result = await new UpdatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(command, default); + + Assert.Equal("Updated Title", result.Title); + Assert.Equal("Updated desc", result.Description); + Assert.Equal("updated-slug", result.Slug); + Assert.Equal("Updated question?", result.Question.Text); + Assert.True(result.Question.AllowMultipleChoices); + } + + [Fact] + public async Task Update_DraftSurvey_ReplaceOptions_Ok() + { + await using var context = new SqliteContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(user, context); + + context.ChangeTracker.Clear(); + + var command = new UpdatePublicSurveyCommand( + survey.Id, + new UpdatePublicSurveyRequest + { + Options = new List + { + new () { Text = "New A", Order = 0 }, + new () { Text = "New B", Order = 1 }, + new () { Text = "New C", Order = 2 }, + }, + }); + + var result = await new UpdatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(command, default); + + Assert.Equal(3, result.Question.Options.Count); + Assert.Equal("New A", result.Question.Options[0].Text); + Assert.Equal("New B", result.Question.Options[1].Text); + Assert.Equal("New C", result.Question.Options[2].Text); + } + + [Fact] + public async Task Update_PublishedSurvey_DescriptionOnly_Ok() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreatePublishedSurvey(user, context); + + var command = new UpdatePublicSurveyCommand( + survey.Id, + new UpdatePublicSurveyRequest + { + Description = "New description", + }); + + context.ChangeTracker.Clear(); + + var result = await new UpdatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(command, default); + + Assert.Equal("New description", result.Description); + } + + [Fact] + public async Task Update_PublishedSurvey_TitleChange_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var user = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreatePublishedSurvey(user, context); + + var command = new UpdatePublicSurveyCommand( + survey.Id, + new UpdatePublicSurveyRequest + { + Title = "New Title", + }); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new UpdatePublicSurveyHandler(context, new FakeAuth(user)) + .Handle(command, default)); + } + + [Fact] + public async Task Update_NotAuthor_Throws() + { + await using var context = new InMemoryDatabaseContext(); + var author = await new UserFake(Role.Interviewer).PleaseAsync(context); + var otherUser = await new UserFake(Role.Interviewer).PleaseAsync(context); + var survey = CreateDraftSurvey(author, context); + + var command = new UpdatePublicSurveyCommand( + survey.Id, + new UpdatePublicSurveyRequest + { + Description = "Hacked", + }); + + context.ChangeTracker.Clear(); + + await Assert.ThrowsAsync(() => + new UpdatePublicSurveyHandler(context, new FakeAuth(otherUser)) + .Handle(command, default)); + } + + private static PublicSurvey CreateDraftSurvey( + Domain.Entities.Users.User user, + DatabaseContext context) + { + var survey = new PublicSurvey("Test Survey", "desc", "test-slug", user.Id); + var question = new PublicSurveyQuestion("Question?", 0, survey); + survey.Questions.Add(question); + question.AddOption("Option A", 0); + question.AddOption("Option B", 1); + context.PublicSurveys.Add(survey); + context.SaveChanges(); + return survey; + } + + private static PublicSurvey CreatePublishedSurvey( + Domain.Entities.Users.User user, + InMemoryDatabaseContext context) + { + var survey = CreateDraftSurvey(user, context); + survey.Publish(); + context.SaveChanges(); + return survey; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandler.cs new file mode 100644 index 00000000..07263270 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/ClosePublicSurvey/ClosePublicSurveyHandler.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.ClosePublicSurvey; + +public class ClosePublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public ClosePublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + Guid surveyId, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .FirstOrDefaultAsync( + s => s.Id == surveyId && s.AuthorId == user.Id, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + survey.Close(); + + await _context.TrySaveChangesAsync(cancellationToken); + + return new PublicSurveyDto(survey, isAuthor: true); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandler.cs new file mode 100644 index 00000000..e15c1a80 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyHandler.cs @@ -0,0 +1,58 @@ +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Validation; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.CreatePublicSurvey; + +public class CreatePublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public CreatePublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + CreatePublicSurveyRequest request, + CancellationToken cancellationToken) + { + request.ThrowIfInvalid(); + + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = new PublicSurvey( + request.Title, + request.Description, + request.Slug, + user.Id); + + var question = new PublicSurveyQuestion( + request.Question, + 0, + survey, + request.AllowMultipleChoices); + + survey.Questions.Add(question); + + for (int i = 0; i < request.Options.Count; i++) + { + question.AddOption(request.Options[i], i); + } + + await _context.PublicSurveys.AddAsync(survey, cancellationToken); + await _context.TrySaveChangesAsync(cancellationToken); + + return new PublicSurveyDto(survey, isAuthor: true); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyRequest.cs b/src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyRequest.cs new file mode 100644 index 00000000..d9ed5924 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/CreatePublicSurvey/CreatePublicSurveyRequest.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Web.Api.Features.PublicSurveys.CreatePublicSurvey; + +public record CreatePublicSurveyRequest +{ + [Required] + [StringLength(500)] + public string Title { get; init; } + + [StringLength(2000)] + public string Description { get; init; } + + [Required] + [StringLength(100)] + public string Slug { get; init; } + + [Required] + [StringLength(500)] + public string Question { get; init; } + + public bool AllowMultipleChoices { get; init; } + + [Required] + [MinLength(2)] + [MaxLength(10)] + public List Options { get; init; } +} diff --git a/src/Web.Api/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandler.cs new file mode 100644 index 00000000..8f4cbf87 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/DeletePublicSurvey/DeletePublicSurveyHandler.cs @@ -0,0 +1,44 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; + +namespace Web.Api.Features.PublicSurveys.DeletePublicSurvey; + +public class DeletePublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public DeletePublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + Guid surveyId, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .FirstOrDefaultAsync( + s => s.Id == surveyId && s.AuthorId == user.Id, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + survey.Delete(); + + await _context.TrySaveChangesAsync(cancellationToken); + + return Nothing.Value; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/Dtos/MySurveyListItemDto.cs b/src/Web.Api/Features/PublicSurveys/Dtos/MySurveyListItemDto.cs new file mode 100644 index 00000000..caa9b3bf --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/Dtos/MySurveyListItemDto.cs @@ -0,0 +1,46 @@ +using System; +using System.Linq; +using Domain.Entities.Surveys; +using Domain.Enums; + +namespace Web.Api.Features.PublicSurveys.Dtos; + +public record MySurveyListItemDto +{ + public Guid Id { get; init; } + + public string Title { get; init; } + + public string Slug { get; init; } + + public PublicSurveyStatus Status { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? PublishedAt { get; init; } + + public DateTimeOffset? DeletedAt { get; init; } + + public int TotalResponses { get; init; } + + public int QuestionCount { get; init; } + + public MySurveyListItemDto() + { + } + + public MySurveyListItemDto(PublicSurvey survey) + { + Id = survey.Id; + Title = survey.Title; + Slug = survey.Slug; + Status = survey.Status; + CreatedAt = survey.CreatedAt; + PublishedAt = survey.PublishedAt; + DeletedAt = survey.DeletedAt; + QuestionCount = survey.Questions?.Count ?? 0; + TotalResponses = survey.Questions? + .SelectMany(q => q.Responses ?? Enumerable.Empty()) + .Count() ?? 0; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyDto.cs b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyDto.cs new file mode 100644 index 00000000..ae4ab035 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyDto.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; +using Domain.Entities.Surveys; +using Domain.Enums; + +namespace Web.Api.Features.PublicSurveys.Dtos; + +public record PublicSurveyDto +{ + public Guid Id { get; init; } + + public string Title { get; init; } + + public string Description { get; init; } + + public string Slug { get; init; } + + public long AuthorId { get; init; } + + public PublicSurveyStatus Status { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? PublishedAt { get; init; } + + public DateTimeOffset? DeletedAt { get; init; } + + public bool IsAuthor { get; init; } + + public PublicSurveyQuestionDto Question { get; init; } + + public PublicSurveyDto() + { + } + + public PublicSurveyDto( + PublicSurvey survey, + bool isAuthor, + bool hasUserResponded = false) + { + Id = survey.Id; + Title = survey.Title; + Description = survey.Description; + Slug = survey.Slug; + AuthorId = survey.AuthorId; + Status = survey.Status; + CreatedAt = survey.CreatedAt; + PublishedAt = survey.PublishedAt; + DeletedAt = survey.DeletedAt; + IsAuthor = isAuthor; + + var question = survey.Questions?.FirstOrDefault(); + if (question != null) + { + Question = new PublicSurveyQuestionDto(question, hasUserResponded); + } + } +} diff --git a/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyOptionDto.cs b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyOptionDto.cs new file mode 100644 index 00000000..cd7ff00e --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyOptionDto.cs @@ -0,0 +1,24 @@ +using System; +using Domain.Entities.Surveys; + +namespace Web.Api.Features.PublicSurveys.Dtos; + +public record PublicSurveyOptionDto +{ + public Guid Id { get; init; } + + public string Text { get; init; } + + public int Order { get; init; } + + public PublicSurveyOptionDto() + { + } + + public PublicSurveyOptionDto(PublicSurveyOption option) + { + Id = option.Id; + Text = option.Text; + Order = option.Order; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyQuestionDto.cs b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyQuestionDto.cs new file mode 100644 index 00000000..6d71b17a --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyQuestionDto.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.Surveys; + +namespace Web.Api.Features.PublicSurveys.Dtos; + +public record PublicSurveyQuestionDto +{ + public Guid Id { get; init; } + + public string Text { get; init; } + + public bool AllowMultipleChoices { get; init; } + + public List Options { get; init; } + + public bool HasUserResponded { get; init; } + + public int TotalResponses { get; init; } + + public PublicSurveyQuestionDto() + { + } + + public PublicSurveyQuestionDto( + PublicSurveyQuestion question, + bool hasUserResponded = false) + { + Id = question.Id; + Text = question.Text; + AllowMultipleChoices = question.AllowMultipleChoices; + Options = question.Options? + .OrderBy(o => o.Order) + .Select(o => new PublicSurveyOptionDto(o)) + .ToList() ?? new List(); + HasUserResponded = hasUserResponded; + TotalResponses = question.Responses?.Count ?? 0; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyResultsDto.cs b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyResultsDto.cs new file mode 100644 index 00000000..fc2eeb05 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/Dtos/PublicSurveyResultsDto.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Web.Api.Features.PublicSurveys.Dtos; + +public record PublicSurveyResultsDto +{ + public int TotalResponses { get; init; } + + public List Options { get; init; } +} + +public record PublicSurveyOptionResultDto +{ + public Guid Id { get; init; } + + public string Text { get; init; } + + public int ResponseCount { get; init; } + + public decimal Percentage { get; init; } +} diff --git a/src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandler.cs b/src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandler.cs new file mode 100644 index 00000000..df77f973 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysHandler.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.GetMyPublicSurveys; + +public class GetMyPublicSurveysHandler + : IRequestHandler> +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public GetMyPublicSurveysHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task> Handle( + GetMyPublicSurveysQuery request, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var query = _context.PublicSurveys + .AsNoTracking() + .Include(s => s.Questions) + .ThenInclude(q => q.Responses) + .Where(s => s.AuthorId == user.Id); + + if (!request.IncludeDeleted) + { + query = query.Where(s => s.DeletedAt == null); + } + + if (request.Status.HasValue) + { + query = query.Where(s => s.Status == request.Status.Value); + } + + var surveys = await query + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(cancellationToken); + + return surveys + .Select(s => new MySurveyListItemDto(s)) + .ToList(); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysQuery.cs b/src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysQuery.cs new file mode 100644 index 00000000..753e8643 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/GetMyPublicSurveys/GetMyPublicSurveysQuery.cs @@ -0,0 +1,10 @@ +using Domain.Enums; + +namespace Web.Api.Features.PublicSurveys.GetMyPublicSurveys; + +public record GetMyPublicSurveysQuery +{ + public bool IncludeDeleted { get; init; } + + public PublicSurveyStatus? Status { get; init; } +} diff --git a/src/Web.Api/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandler.cs b/src/Web.Api/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandler.cs new file mode 100644 index 00000000..d57de3ef --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/GetPublicSurveyBySlug/GetPublicSurveyBySlugHandler.cs @@ -0,0 +1,71 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Enums; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.GetPublicSurveyBySlug; + +public class GetPublicSurveyBySlugHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public GetPublicSurveyBySlugHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + string slug, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrNullAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .AsNoTracking() + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .Include(s => s.Questions) + .ThenInclude(q => q.Responses) + .FirstOrDefaultAsync( + s => s.Slug == slug, + cancellationToken); + + if (survey == null) + { + throw new NotFoundException("Survey not found."); + } + + var isAuthor = user != null && survey.AuthorId == user.Id; + + if (!isAuthor) + { + if (survey.DeletedAt != null || survey.Status == PublicSurveyStatus.Draft) + { + throw new NotFoundException("Survey not found."); + } + } + + var hasUserResponded = false; + if (user != null) + { + var question = survey.Questions.FirstOrDefault(); + if (question != null) + { + hasUserResponded = question.Responses?.Any(r => r.UserId == user.Id) ?? false; + } + } + + return new PublicSurveyDto(survey, isAuthor, hasUserResponded); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandler.cs b/src/Web.Api/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandler.cs new file mode 100644 index 00000000..7b33ef64 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/GetPublicSurveyResults/GetPublicSurveyResultsHandler.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Enums; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.GetPublicSurveyResults; + +public class GetPublicSurveyResultsHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public GetPublicSurveyResultsHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + string slug, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .AsNoTracking() + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .ThenInclude(o => o.ResponseOptions) + .Include(s => s.Questions) + .ThenInclude(q => q.Responses) + .FirstOrDefaultAsync( + s => s.Slug == slug && s.DeletedAt == null, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + if (survey.Status == PublicSurveyStatus.Draft) + { + throw new NotFoundException("Survey not found."); + } + + var question = survey.Questions.FirstOrDefault() + ?? throw new BadRequestException("Survey has no questions."); + + var hasResponded = question.Responses?.Any(r => r.UserId == user.Id) ?? false; + if (!hasResponded) + { + throw new BadRequestException("You must respond to the survey to view results."); + } + + var totalResponses = question.Responses?.Count ?? 0; + + var options = question.Options + .OrderBy(o => o.Order) + .Select(o => + { + var responseCount = o.ResponseOptions?.Count ?? 0; + return new PublicSurveyOptionResultDto + { + Id = o.Id, + Text = o.Text, + ResponseCount = responseCount, + Percentage = totalResponses > 0 + ? Math.Round((decimal)responseCount / totalResponses * 100, 1) + : 0, + }; + }) + .ToList(); + + return new PublicSurveyResultsDto + { + TotalResponses = totalResponses, + Options = options, + }; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandler.cs new file mode 100644 index 00000000..9eb5b13d --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/PublishPublicSurvey/PublishPublicSurveyHandler.cs @@ -0,0 +1,48 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.PublishPublicSurvey; + +public class PublishPublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public PublishPublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + Guid surveyId, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .FirstOrDefaultAsync( + s => s.Id == surveyId && s.AuthorId == user.Id, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + survey.Publish(); + + await _context.TrySaveChangesAsync(cancellationToken); + + return new PublicSurveyDto(survey, isAuthor: true); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandler.cs new file mode 100644 index 00000000..df4c8a7b --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/ReopenPublicSurvey/ReopenPublicSurveyHandler.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.ReopenPublicSurvey; + +public class ReopenPublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public ReopenPublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + Guid surveyId, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .FirstOrDefaultAsync( + s => s.Id == surveyId && s.AuthorId == user.Id, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + survey.Reopen(); + + await _context.TrySaveChangesAsync(cancellationToken); + + return new PublicSurveyDto(survey, isAuthor: true); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandler.cs new file mode 100644 index 00000000..78d7d67e --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/RestorePublicSurvey/RestorePublicSurveyHandler.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.RestorePublicSurvey; + +public class RestorePublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public RestorePublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + Guid surveyId, + CancellationToken cancellationToken) + { + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .FirstOrDefaultAsync( + s => s.Id == surveyId && s.AuthorId == user.Id, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + survey.Restore(); + + await _context.TrySaveChangesAsync(cancellationToken); + + return new PublicSurveyDto(survey, isAuthor: true); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseCommand.cs b/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseCommand.cs new file mode 100644 index 00000000..e4f7e62a --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseCommand.cs @@ -0,0 +1,16 @@ +namespace Web.Api.Features.PublicSurveys.SubmitPublicSurveyResponse; + +public record SubmitPublicSurveyResponseCommand +{ + public SubmitPublicSurveyResponseCommand( + string slug, + SubmitPublicSurveyResponseRequest body) + { + Slug = slug; + Body = body; + } + + public string Slug { get; } + + public SubmitPublicSurveyResponseRequest Body { get; } +} diff --git a/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandler.cs b/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandler.cs new file mode 100644 index 00000000..44cd1d43 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseHandler.cs @@ -0,0 +1,86 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Surveys; +using Domain.Validation; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; + +namespace Web.Api.Features.PublicSurveys.SubmitPublicSurveyResponse; + +public class SubmitPublicSurveyResponseHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public SubmitPublicSurveyResponseHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + SubmitPublicSurveyResponseCommand request, + CancellationToken cancellationToken) + { + request.Body.ThrowIfInvalid(); + + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .Include(s => s.Questions) + .ThenInclude(q => q.Responses) + .FirstOrDefaultAsync( + s => s.Slug == request.Slug && s.DeletedAt == null, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + if (!survey.CanAcceptResponses()) + { + throw new BadRequestException("This survey is not accepting responses."); + } + + var question = survey.Questions.FirstOrDefault() + ?? throw new BadRequestException("Survey has no questions."); + + var alreadyResponded = question.Responses?.Any(r => r.UserId == user.Id) ?? false; + if (alreadyResponded) + { + throw new BadRequestException("You have already responded to this survey."); + } + + if (!question.AllowMultipleChoices && request.Body.OptionIds.Count > 1) + { + throw new BadRequestException("This question allows only one choice."); + } + + var selectedOptions = question.Options + .Where(o => request.Body.OptionIds.Contains(o.Id)) + .ToList(); + + if (selectedOptions.Count != request.Body.OptionIds.Count) + { + throw new BadRequestException("One or more selected options are invalid."); + } + + var response = new PublicSurveyResponse(question, user.Id); + + foreach (var option in selectedOptions) + { + response.AddSelectedOption(option); + } + + await _context.PublicSurveyResponses.AddAsync(response, cancellationToken); + await _context.TrySaveChangesAsync(cancellationToken); + + return Nothing.Value; + } +} diff --git a/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseRequest.cs b/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseRequest.cs new file mode 100644 index 00000000..358017ff --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/SubmitPublicSurveyResponse/SubmitPublicSurveyResponseRequest.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Web.Api.Features.PublicSurveys.SubmitPublicSurveyResponse; + +public record SubmitPublicSurveyResponseRequest +{ + [Required] + [MinLength(1)] + public List OptionIds { get; init; } +} diff --git a/src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandler.cs b/src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandler.cs new file mode 100644 index 00000000..dc79c826 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyHandler.cs @@ -0,0 +1,104 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation; +using Domain.Validation.Exceptions; +using Infrastructure.Authentication.Contracts; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.PublicSurveys.Dtos; + +namespace Web.Api.Features.PublicSurveys.UpdatePublicSurvey; + +public class UpdatePublicSurveyHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + private readonly IAuthorization _authorization; + + public UpdatePublicSurveyHandler( + DatabaseContext context, + IAuthorization authorization) + { + _context = context; + _authorization = authorization; + } + + public async Task Handle( + UpdatePublicSurveyCommand request, + CancellationToken cancellationToken) + { + request.Body.ThrowIfInvalid(); + + var user = await _authorization.GetCurrentUserOrFailAsync(cancellationToken); + + var survey = await _context.PublicSurveys + .Include(s => s.Questions) + .ThenInclude(q => q.Options) + .Include(s => s.Questions) + .ThenInclude(q => q.Responses) + .FirstOrDefaultAsync( + s => s.Id == request.SurveyId && s.AuthorId == user.Id, + cancellationToken) + ?? throw new NotFoundException("Survey not found."); + + if (request.Body.Description != null) + { + survey.UpdateDescription(request.Body.Description); + } + + if (survey.IsDraft()) + { + if (request.Body.Title != null) + { + survey.UpdateTitle(request.Body.Title); + } + + if (request.Body.Slug != null) + { + survey.UpdateSlug(request.Body.Slug); + } + + var question = survey.Questions.FirstOrDefault(); + if (question != null) + { + if (request.Body.Question != null) + { + question.UpdateText(request.Body.Question); + } + + if (request.Body.AllowMultipleChoices.HasValue) + { + question.SetAllowMultipleChoices(request.Body.AllowMultipleChoices.Value); + } + + if (request.Body.Options != null) + { + _context.PublicSurveyOptions.RemoveRange(question.Options); + question.Options.Clear(); + for (int i = 0; i < request.Body.Options.Count; i++) + { + var optionRequest = request.Body.Options[i]; + question.AddOption(optionRequest.Text, optionRequest.Order); + } + } + } + } + else + { + if (request.Body.Title != null || + request.Body.Slug != null || + request.Body.Question != null || + request.Body.AllowMultipleChoices.HasValue || + request.Body.Options != null) + { + throw new BadRequestException("Only description can be updated for published or closed surveys."); + } + } + + await _context.TrySaveChangesAsync(cancellationToken); + + return new PublicSurveyDto(survey, isAuthor: true); + } +} diff --git a/src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyRequest.cs b/src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyRequest.cs new file mode 100644 index 00000000..e8ad28dc --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/UpdatePublicSurvey/UpdatePublicSurveyRequest.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Web.Api.Features.PublicSurveys.UpdatePublicSurvey; + +public record UpdatePublicSurveyRequest +{ + [StringLength(500)] + public string Title { get; init; } + + [StringLength(2000)] + public string Description { get; init; } + + [StringLength(100)] + public string Slug { get; init; } + + [StringLength(500)] + public string Question { get; init; } + + public bool? AllowMultipleChoices { get; init; } + + [MaxLength(10)] + public List Options { get; init; } +} + +public record UpdatePublicSurveyOptionRequest +{ + [Required] + [StringLength(200)] + public string Text { get; init; } + + public int Order { get; init; } +} + +public record UpdatePublicSurveyCommand +{ + public UpdatePublicSurveyCommand( + Guid surveyId, + UpdatePublicSurveyRequest body) + { + SurveyId = surveyId; + Body = body; + } + + public Guid SurveyId { get; } + + public UpdatePublicSurveyRequest Body { get; } +} From c727298836d04c07bc91a71d158da37c309cd488 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Thu, 12 Feb 2026 20:28:02 +0500 Subject: [PATCH 3/4] phase 4 --- .../PublicSurveys/PublicSurveysController.cs | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 src/Web.Api/Features/PublicSurveys/PublicSurveysController.cs diff --git a/src/Web.Api/Features/PublicSurveys/PublicSurveysController.cs b/src/Web.Api/Features/PublicSurveys/PublicSurveysController.cs new file mode 100644 index 00000000..5500fe55 --- /dev/null +++ b/src/Web.Api/Features/PublicSurveys/PublicSurveysController.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Domain.Validation; +using Infrastructure.Services.Mediator; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Web.Api.Features.PublicSurveys.ClosePublicSurvey; +using Web.Api.Features.PublicSurveys.CreatePublicSurvey; +using Web.Api.Features.PublicSurveys.DeletePublicSurvey; +using Web.Api.Features.PublicSurveys.Dtos; +using Web.Api.Features.PublicSurveys.GetMyPublicSurveys; +using Web.Api.Features.PublicSurveys.GetPublicSurveyBySlug; +using Web.Api.Features.PublicSurveys.GetPublicSurveyResults; +using Web.Api.Features.PublicSurveys.PublishPublicSurvey; +using Web.Api.Features.PublicSurveys.ReopenPublicSurvey; +using Web.Api.Features.PublicSurveys.RestorePublicSurvey; +using Web.Api.Features.PublicSurveys.SubmitPublicSurveyResponse; +using Web.Api.Features.PublicSurveys.UpdatePublicSurvey; +using Web.Api.Setup.Attributes; + +namespace Web.Api.Features.PublicSurveys; + +[ApiController] +[Route("api/public-surveys")] +[HasAnyRole] +public class PublicSurveysController : ControllerBase +{ + private readonly IServiceProvider _serviceProvider; + + public PublicSurveysController( + IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + [HttpPost] + public async Task Create( + [FromBody] CreatePublicSurveyRequest request, + CancellationToken cancellationToken) + { + request.ThrowIfInvalid(); + + return Ok( + await _serviceProvider.HandleBy( + request, cancellationToken)); + } + + [HttpGet("my-surveys")] + public async Task GetMySurveys( + [FromQuery] GetMyPublicSurveysQuery query, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy>( + query, cancellationToken)); + } + + [HttpGet("{slug}")] + [AllowAnonymous] + public async Task GetBySlug( + [FromRoute] string slug, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy( + slug, cancellationToken)); + } + + [HttpPatch("{id:guid}")] + public async Task Update( + [FromRoute] Guid id, + [FromBody] UpdatePublicSurveyRequest request, + CancellationToken cancellationToken) + { + request.ThrowIfInvalid(); + + return Ok( + await _serviceProvider.HandleBy( + new UpdatePublicSurveyCommand(id, request), cancellationToken)); + } + + [HttpPost("{id:guid}/publish")] + public async Task Publish( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy( + id, cancellationToken)); + } + + [HttpPost("{id:guid}/close")] + public async Task Close( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy( + id, cancellationToken)); + } + + [HttpPost("{id:guid}/reopen")] + public async Task Reopen( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy( + id, cancellationToken)); + } + + [HttpDelete("{id:guid}")] + public async Task Delete( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + await _serviceProvider.HandleBy( + id, cancellationToken); + + return Ok(); + } + + [HttpPost("{id:guid}/restore")] + public async Task Restore( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy( + id, cancellationToken)); + } + + [HttpPost("{slug}/responses")] + public async Task SubmitResponse( + [FromRoute] string slug, + [FromBody] SubmitPublicSurveyResponseRequest request, + CancellationToken cancellationToken) + { + request.ThrowIfInvalid(); + + await _serviceProvider.HandleBy( + new SubmitPublicSurveyResponseCommand(slug, request), cancellationToken); + + return Ok(); + } + + [HttpGet("{slug}/results")] + public async Task GetResults( + [FromRoute] string slug, + CancellationToken cancellationToken) + { + return Ok( + await _serviceProvider.HandleBy( + slug, cancellationToken)); + } +} From 5dcc031f31fbd21fe8c6356cd177211a2607e707 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Thu, 12 Feb 2026 20:46:50 +0500 Subject: [PATCH 4/4] Migration added --- ...4526_PublicSurveyEntitiesAdded.Designer.cs | 2421 +++++++++++++++++ ...0260212154526_PublicSurveyEntitiesAdded.cs | 190 ++ .../DatabaseContextModelSnapshot.cs | 238 ++ 3 files changed, 2849 insertions(+) create mode 100644 src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.Designer.cs create mode 100644 src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.cs diff --git a/src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.Designer.cs b/src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.Designer.cs new file mode 100644 index 00000000..ce26431f --- /dev/null +++ b/src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.Designer.cs @@ -0,0 +1,2421 @@ +// +using System; +using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Domain.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260212154526_PublicSurveyEntitiesAdded")] + partial class PublicSurveyEntitiesAdded + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.Auth.M2mClient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecretHash") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUsedIpAddress") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RateLimitPerDay") + .HasColumnType("integer"); + + b.Property("RateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("Name"); + + b.ToTable("M2mClients"); + }); + + modelBuilder.Entity("Domain.Entities.Auth.M2mClientScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("M2mClientId") + .HasColumnType("bigint"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("M2mClientId", "Scope") + .IsUnique(); + + b.ToTable("M2mClientScopes"); + }); + + modelBuilder.Entity("Domain.Entities.Auth.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Domain.Entities.CSV.UserCsvDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserCsvDownloads", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Links") + .HasColumnType("text"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.Property("ReviewsCount") + .HasColumnType("integer"); + + b.Property("Slug") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ViewsCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Companies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyOpenAiAnalysis", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnalysisText") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompanyId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("CompanyOpenAiAnalysisRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyRatingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompanyId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("CompanyRatingHistory", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CareerOpportunities") + .HasColumnType("integer"); + + b.Property("CodeQuality") + .HasColumnType("integer"); + + b.Property("CompanyId") + .HasColumnType("uuid"); + + b.Property("CompensationAndBenefits") + .HasColumnType("integer"); + + b.Property("Cons") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CultureAndValues") + .HasColumnType("integer"); + + b.Property("DislikesCount") + .HasColumnType("integer"); + + b.Property("IWorkHere") + .HasColumnType("boolean"); + + b.Property("LikesCount") + .HasColumnType("integer"); + + b.Property("Management") + .HasColumnType("integer"); + + b.Property("OutdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Pros") + .HasColumnType("text"); + + b.Property("TotalRating") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserEmployment") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("WorkLifeBalance") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("UserId"); + + b.ToTable("CompanyReviews", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReviewVote", b => + { + b.Property("ReviewId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VoteType") + .HasColumnType("integer"); + + b.HasKey("ReviewId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("CompanyReviewVotes"); + }); + + modelBuilder.Entity("Domain.Entities.Currencies.CurrenciesCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currencies") + .HasColumnType("text"); + + b.Property("CurrencyDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyDate") + .IsUnique(); + + b.ToTable("CurrenciesCollections", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubPersonalUserToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("GithubPersonalUserTokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfile", b => + { + b.Property("Username") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DataSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestsCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Username"); + + b.ToTable("GithubProfiles", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("MessagesCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChatId") + .IsUnique(); + + b.ToTable("GithubProfileBotChats", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GithubProfileBotChatId") + .HasColumnType("uuid"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GithubProfileBotChatId"); + + b.ToTable("GithubProfileBotMessages", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileProcessingJob", b => + { + b.Property("Username") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Username"); + + b.ToTable("GithubProfileProcessingJobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("TemplateId"); + + b.ToTable("SalariesHistoricalDataRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecordTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfessionIds") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SalariesHistoricalDataRecordTemplates", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.Interview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CandidateGrade") + .HasColumnType("bigint"); + + b.Property("CandidateName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InterviewerId") + .HasColumnType("bigint"); + + b.Property("OverallOpinion") + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("Subjects") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InterviewerId"); + + b.ToTable("Interviews", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.InterviewTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("OverallOpinion") + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("Subjects") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("InterviewTemplates", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InterviewId") + .HasColumnType("uuid"); + + b.Property("ShareToken") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InterviewId") + .IsUnique(); + + b.ToTable("ShareLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Labels.UserLabel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("UserLabels", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Prompts.OpenAiPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Engine") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive"); + + b.ToTable("OpenAiPrompts", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Questions.SalariesSurveyReply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsefulnessRating") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SalariesSurveyReplies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Profession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("Professions", (string)null); + + b.HasData( + new + { + Id = 1L, + HexColor = "#C00C50", + Title = "Developer" + }, + new + { + Id = 2L, + HexColor = "#C00C50", + Title = "QualityAssurance" + }, + new + { + Id = 3L, + HexColor = "#C00C50", + Title = "Tester" + }, + new + { + Id = 4L, + HexColor = "#C00C50", + Title = "BusinessAnalyst" + }, + new + { + Id = 5L, + HexColor = "#C00C50", + Title = "ProjectManager" + }, + new + { + Id = 6L, + HexColor = "#C00C50", + Title = "ScrumMaster" + }, + new + { + Id = 7L, + HexColor = "#C00C50", + Title = "DevOps" + }, + new + { + Id = 8L, + HexColor = "#C00C50", + Title = "SystemAdministrator" + }, + new + { + Id = 9L, + HexColor = "#C00C50", + Title = "ProductOwner" + }, + new + { + Id = 10L, + HexColor = "#C00C50", + Title = "TeamLeader" + }, + new + { + Id = 11L, + HexColor = "#C00C50", + Title = "Architect" + }, + new + { + Id = 12L, + HexColor = "#C00C50", + Title = "DataScientist" + }, + new + { + Id = 13L, + HexColor = "#C00C50", + Title = "DataAnalyst" + }, + new + { + Id = 14L, + HexColor = "#C00C50", + Title = "DataEngineer" + }, + new + { + Id = 15L, + HexColor = "#C00C50", + Title = "DataWarehouseSpecialist" + }, + new + { + Id = 16L, + HexColor = "#C00C50", + Title = "DatabaseAdministrator" + }, + new + { + Id = 17L, + HexColor = "#C00C50", + Title = "TechLeader" + }, + new + { + Id = 18L, + HexColor = "#C00C50", + Title = "SystemAnalyst" + }, + new + { + Id = 19L, + HexColor = "#C00C50", + Title = "ItHr" + }, + new + { + Id = 20L, + HexColor = "#C00C50", + Title = "ItRecruiter" + }, + new + { + Id = 21L, + HexColor = "#C00C50", + Title = "UiDesigner" + }, + new + { + Id = 22L, + HexColor = "#C00C50", + Title = "UxDesigner" + }, + new + { + Id = 23L, + HexColor = "#C00C50", + Title = "UiUxDesigner" + }, + new + { + Id = 24L, + HexColor = "#C00C50", + Title = "ProductAnalyst" + }, + new + { + Id = 25L, + HexColor = "#C00C50", + Title = "ProductManager" + }, + new + { + Id = 26L, + HexColor = "#C00C50", + Title = "ProductDesigner" + }, + new + { + Id = 27L, + HexColor = "#C00C50", + Title = "HrNonIt" + }, + new + { + Id = 28L, + HexColor = "#C00C50", + Title = "OneCDeveloper" + }, + new + { + Id = 29L, + HexColor = "#C00C50", + Title = "ThreeDModeler" + }, + new + { + Id = 30L, + HexColor = "#C00C50", + Title = "AndroidDeveloper" + }, + new + { + Id = 31L, + HexColor = "#C00C50", + Title = "IosDeveloper" + }, + new + { + Id = 32L, + HexColor = "#C00C50", + Title = "MobileDeveloper" + }, + new + { + Id = 33L, + HexColor = "#C00C50", + Title = "FrontendDeveloper" + }, + new + { + Id = 34L, + HexColor = "#C00C50", + Title = "BackendDeveloper" + }, + new + { + Id = 35L, + HexColor = "#C00C50", + Title = "FullstackDeveloper" + }, + new + { + Id = 36L, + HexColor = "#C00C50", + Title = "GameDeveloper" + }, + new + { + Id = 37L, + HexColor = "#C00C50", + Title = "EmbeddedDeveloper" + }, + new + { + Id = 38L, + HexColor = "#C00C50", + Title = "MachineLearningDeveloper" + }, + new + { + Id = 39L, + HexColor = "#C00C50", + Title = "Pentester" + }, + new + { + Id = 40L, + HexColor = "#C00C50", + Title = "SecurityEngineer" + }, + new + { + Id = 41L, + HexColor = "#C00C50", + Title = "SecurityAnalyst" + }, + new + { + Id = 42L, + HexColor = "#C00C50", + Title = "TechnicalWriter" + }, + new + { + Id = 43L, + HexColor = "#C00C50", + Title = "BiDeveloper" + }, + new + { + Id = 44L, + HexColor = "#C00C50", + Title = "ChiefTechnicalOfficer" + }, + new + { + Id = 45L, + HexColor = "#C00C50", + Title = "ChiefExecutiveOfficer" + }, + new + { + Id = 46L, + HexColor = "#C00C50", + Title = "HeadOfDepartment" + }, + new + { + Id = 47L, + HexColor = "#C00C50", + Title = "DeliveryManager" + }, + new + { + Id = 48L, + HexColor = "#C00C50", + Title = "Copywriter" + }, + new + { + Id = 49L, + HexColor = "#C00C50", + Title = "GameDesigner" + }, + new + { + Id = 50L, + HexColor = "#C00C50", + Title = "SecOps" + }, + new + { + Id = 51L, + HexColor = "#C00C50", + Title = "TalentAcquisition" + }, + new + { + Id = 52L, + HexColor = "#C00C50", + Title = "CustomerSupport" + }, + new + { + Id = 53L, + HexColor = "#C00C50", + Title = "TechnicalSupport" + }); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("Skills", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.UserSalary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Age") + .HasColumnType("integer"); + + b.Property("City") + .HasColumnType("bigint"); + + b.Property("Company") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("Grade") + .HasColumnType("bigint"); + + b.Property("ProfessionEnum") + .HasColumnType("integer"); + + b.Property("ProfessionId") + .HasColumnType("bigint"); + + b.Property("Quarter") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("bigint"); + + b.Property("SourceType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseInStats") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.Property("WorkIndustryId") + .HasColumnType("bigint"); + + b.Property("Year") + .HasColumnType("integer"); + + b.Property("YearOfStartingWork") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProfessionId"); + + b.HasIndex("SkillId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkIndustryId"); + + b.ToTable("UserSalaries", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.WorkIndustry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("WorkIndustries", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.AiAnalysisRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiReport") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiReportSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompanyReviewsSubscriptionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("ProcessingTimeMs") + .HasColumnType("double precision"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReviewsSubscriptionId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("AiAnalysisRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Regularity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Weekly"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseAiAnalysis") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("LastWeekCompanyReviewsSubscriptions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.JobPostingMessageSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfessionIds") + .HasColumnType("text"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TelegramChatId") + .IsUnique(); + + b.ToTable("JobPostingMessageSubscriptions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PreventNotificationIfNoDifference") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ProfessionIds") + .HasColumnType("text"); + + b.Property("Regularity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Weekly"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseAiAnalysis") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("StatDataChangeSubscriptions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("PreviousRecordId") + .HasColumnType("uuid"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PreviousRecordId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("StatDataChangeSubscriptionRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.SubscriptionTelegramMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CompanyReviewsSubscriptionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("SalarySubscriptionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReviewsSubscriptionId"); + + b.HasIndex("SalarySubscriptionId"); + + b.ToTable("StatDataChangeSubscriptionTgMessages", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurvey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Slug") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("Status"); + + b.ToTable("PublicSurveys"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyOption", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId", "Order") + .IsUnique(); + + b.ToTable("PublicSurveyOptions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyQuestion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowMultipleChoices") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PublicSurveyId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PublicSurveyId", "Order") + .IsUnique(); + + b.ToTable("PublicSurveyQuestions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponse", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId", "UserId") + .IsUnique(); + + b.ToTable("PublicSurveyResponses"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponseOption", b => + { + b.Property("ResponseId") + .HasColumnType("uuid"); + + b.Property("OptionId") + .HasColumnType("uuid"); + + b.HasKey("ResponseId", "OptionId"); + + b.HasIndex("OptionId"); + + b.ToTable("PublicSurveyResponseOptions"); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.SalariesBotMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("UsageType") + .HasColumnType("integer"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SalariesBotMessages", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.TelegramInlineReply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BotType") + .HasColumnType("integer"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InlineQuery") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TelegramInlineReplies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.TelegramUserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SendBotRegularStatsUpdates") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChatId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("TelegramUserSettings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("EmailVerificationToken") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmailVerificationTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FailedLoginAttempts") + .HasColumnType("integer"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IdentityId") + .HasColumnType("text"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LockedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PasswordResetToken") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordResetTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProfilePicture") + .HasColumnType("text"); + + b.Property("TotpSecret") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TotpVerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UniqueToken") + .HasColumnType("text"); + + b.Property("UnsubscribeMeFromAll") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("EmailVerificationToken"); + + b.HasIndex("IdentityId") + .IsUnique(); + + b.HasIndex("PasswordResetToken"); + + b.HasIndex("UniqueToken") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailSubject") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEmails"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserRole", b => + { + b.Property("RoleId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("RoleId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserRole"); + }); + + modelBuilder.Entity("InterviewTemplateUserLabel", b => + { + b.Property("InterviewTemplatesId") + .HasColumnType("uuid"); + + b.Property("LabelsId") + .HasColumnType("bigint"); + + b.HasKey("InterviewTemplatesId", "LabelsId"); + + b.HasIndex("LabelsId"); + + b.ToTable("InterviewTemplateUserLabel"); + }); + + modelBuilder.Entity("InterviewUserLabel", b => + { + b.Property("InterviewsId") + .HasColumnType("uuid"); + + b.Property("LabelsId") + .HasColumnType("bigint"); + + b.HasKey("InterviewsId", "LabelsId"); + + b.HasIndex("LabelsId"); + + b.ToTable("InterviewUserLabel"); + }); + + modelBuilder.Entity("Domain.Entities.Auth.M2mClient", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Domain.Entities.Auth.M2mClientScope", b => + { + b.HasOne("Domain.Entities.Auth.M2mClient", "M2mClient") + .WithMany("Scopes") + .HasForeignKey("M2mClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("M2mClient"); + }); + + modelBuilder.Entity("Domain.Entities.Auth.RefreshToken", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.CSV.UserCsvDownload", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyOpenAiAnalysis", b => + { + b.HasOne("Domain.Entities.Companies.Company", "Company") + .WithMany("OpenAiAnalysisRecords") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyRatingHistory", b => + { + b.HasOne("Domain.Entities.Companies.Company", "Company") + .WithMany("RatingHistory") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReview", b => + { + b.HasOne("Domain.Entities.Companies.Company", "Company") + .WithMany("Reviews") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Reviews") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReviewVote", b => + { + b.HasOne("Domain.Entities.Companies.CompanyReview", "CompanyReview") + .WithMany("Votes") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Votes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompanyReview"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotMessage", b => + { + b.HasOne("Domain.Entities.Github.GithubProfileBotChat", "GithubProfileBotChat") + .WithMany("Messages") + .HasForeignKey("GithubProfileBotChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GithubProfileBotChat"); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecord", b => + { + b.HasOne("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecordTemplate", "SalariesHistoricalDataRecordTemplate") + .WithMany("SalariesHistoricalDataRecords") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SalariesHistoricalDataRecordTemplate"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.Interview", b => + { + b.HasOne("Domain.Entities.Users.User", "Interviewer") + .WithMany() + .HasForeignKey("InterviewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Interviewer"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.InterviewTemplate", b => + { + b.HasOne("Domain.Entities.Users.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.ShareLink", b => + { + b.HasOne("Domain.Entities.Interviews.Interview", "Interview") + .WithOne("ShareLink") + .HasForeignKey("Domain.Entities.Interviews.ShareLink", "InterviewId"); + + b.Navigation("Interview"); + }); + + modelBuilder.Entity("Domain.Entities.Labels.UserLabel", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.Questions.SalariesSurveyReply", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Profession", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Skill", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.UserSalary", b => + { + b.HasOne("Domain.Entities.Salaries.Profession", "Profession") + .WithMany("Salaries") + .HasForeignKey("ProfessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Domain.Entities.Salaries.Skill", "Skill") + .WithMany("Salaries") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Salaries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Domain.Entities.Salaries.WorkIndustry", "WorkIndustry") + .WithMany("Salaries") + .HasForeignKey("WorkIndustryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Profession"); + + b.Navigation("Skill"); + + b.Navigation("User"); + + b.Navigation("WorkIndustry"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.WorkIndustry", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.AiAnalysisRecord", b => + { + b.HasOne("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", "CompanyReviewsSubscription") + .WithMany("AiAnalysisRecords") + .HasForeignKey("CompanyReviewsSubscriptionId"); + + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscription", "SalarySubscription") + .WithMany("AiAnalysisRecords") + .HasForeignKey("SubscriptionId"); + + b.Navigation("CompanyReviewsSubscription"); + + b.Navigation("SalarySubscription"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", b => + { + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", "PreviousRecord") + .WithMany("NextRecords") + .HasForeignKey("PreviousRecordId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_StatDataChangeSubscriptionRecord_PreviousRecord"); + + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscription", "Subscription") + .WithMany("Records") + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_StatDataChangeSubscriptionRecord_Subscription"); + + b.Navigation("PreviousRecord"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.SubscriptionTelegramMessage", b => + { + b.HasOne("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", "CompanyReviewsSubscription") + .WithMany("TelegramMessages") + .HasForeignKey("CompanyReviewsSubscriptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscription", "SalarySubscription") + .WithMany("StatDataChangeSubscriptionTgMessages") + .HasForeignKey("SalarySubscriptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("CompanyReviewsSubscription"); + + b.Navigation("SalarySubscription"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurvey", b => + { + b.HasOne("Domain.Entities.Users.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyOption", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurveyQuestion", "Question") + .WithMany("Options") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyQuestion", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurvey", "PublicSurvey") + .WithMany("Questions") + .HasForeignKey("PublicSurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PublicSurvey"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponse", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurveyQuestion", "Question") + .WithMany("Responses") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponseOption", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurveyOption", "Option") + .WithMany("ResponseOptions") + .HasForeignKey("OptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Surveys.PublicSurveyResponse", "Response") + .WithMany("SelectedOptions") + .HasForeignKey("ResponseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Option"); + + b.Navigation("Response"); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.TelegramUserSettings", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserEmail", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Emails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserRole", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("InterviewTemplateUserLabel", b => + { + b.HasOne("Domain.Entities.Interviews.InterviewTemplate", null) + .WithMany() + .HasForeignKey("InterviewTemplatesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Labels.UserLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InterviewUserLabel", b => + { + b.HasOne("Domain.Entities.Interviews.Interview", null) + .WithMany() + .HasForeignKey("InterviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Labels.UserLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Domain.Entities.Auth.M2mClient", b => + { + b.Navigation("Scopes"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.Company", b => + { + b.Navigation("OpenAiAnalysisRecords"); + + b.Navigation("RatingHistory"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReview", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecordTemplate", b => + { + b.Navigation("SalariesHistoricalDataRecords"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.Interview", b => + { + b.Navigation("ShareLink"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Profession", b => + { + b.Navigation("Salaries"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Skill", b => + { + b.Navigation("Salaries"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.WorkIndustry", b => + { + b.Navigation("Salaries"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", b => + { + b.Navigation("AiAnalysisRecords"); + + b.Navigation("TelegramMessages"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscription", b => + { + b.Navigation("AiAnalysisRecords"); + + b.Navigation("Records"); + + b.Navigation("StatDataChangeSubscriptionTgMessages"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", b => + { + b.Navigation("NextRecords"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurvey", b => + { + b.Navigation("Questions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyOption", b => + { + b.Navigation("ResponseOptions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyQuestion", b => + { + b.Navigation("Options"); + + b.Navigation("Responses"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponse", b => + { + b.Navigation("SelectedOptions"); + }); + + modelBuilder.Entity("Domain.Entities.Users.User", b => + { + b.Navigation("Emails"); + + b.Navigation("Reviews"); + + b.Navigation("Salaries"); + + b.Navigation("UserRoles"); + + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.cs b/src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.cs new file mode 100644 index 00000000..adc98918 --- /dev/null +++ b/src/Infrastructure/Migrations/20260212154526_PublicSurveyEntitiesAdded.cs @@ -0,0 +1,190 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Domain.Migrations +{ + /// + public partial class PublicSurveyEntitiesAdded : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PublicSurveys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Title = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Slug = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + AuthorId = table.Column(type: "bigint", nullable: false), + Status = table.Column(type: "integer", nullable: false), + PublishedAt = table.Column(type: "timestamp with time zone", nullable: true), + DeletedAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PublicSurveys", x => x.Id); + table.ForeignKey( + name: "FK_PublicSurveys_Users_AuthorId", + column: x => x.AuthorId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "PublicSurveyQuestions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PublicSurveyId = table.Column(type: "uuid", nullable: false), + Text = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + Order = table.Column(type: "integer", nullable: false), + AllowMultipleChoices = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PublicSurveyQuestions", x => x.Id); + table.ForeignKey( + name: "FK_PublicSurveyQuestions_PublicSurveys_PublicSurveyId", + column: x => x.PublicSurveyId, + principalTable: "PublicSurveys", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PublicSurveyOptions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + QuestionId = table.Column(type: "uuid", nullable: false), + Text = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Order = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PublicSurveyOptions", x => x.Id); + table.ForeignKey( + name: "FK_PublicSurveyOptions_PublicSurveyQuestions_QuestionId", + column: x => x.QuestionId, + principalTable: "PublicSurveyQuestions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PublicSurveyResponses", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + QuestionId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "bigint", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PublicSurveyResponses", x => x.Id); + table.ForeignKey( + name: "FK_PublicSurveyResponses_PublicSurveyQuestions_QuestionId", + column: x => x.QuestionId, + principalTable: "PublicSurveyQuestions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "PublicSurveyResponseOptions", + columns: table => new + { + ResponseId = table.Column(type: "uuid", nullable: false), + OptionId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PublicSurveyResponseOptions", x => new { x.ResponseId, x.OptionId }); + table.ForeignKey( + name: "FK_PublicSurveyResponseOptions_PublicSurveyOptions_OptionId", + column: x => x.OptionId, + principalTable: "PublicSurveyOptions", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_PublicSurveyResponseOptions_PublicSurveyResponses_ResponseId", + column: x => x.ResponseId, + principalTable: "PublicSurveyResponses", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveyOptions_QuestionId_Order", + table: "PublicSurveyOptions", + columns: new[] { "QuestionId", "Order" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveyQuestions_PublicSurveyId_Order", + table: "PublicSurveyQuestions", + columns: new[] { "PublicSurveyId", "Order" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveyResponseOptions_OptionId", + table: "PublicSurveyResponseOptions", + column: "OptionId"); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveyResponses_QuestionId_UserId", + table: "PublicSurveyResponses", + columns: new[] { "QuestionId", "UserId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveys_AuthorId", + table: "PublicSurveys", + column: "AuthorId"); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveys_Slug", + table: "PublicSurveys", + column: "Slug", + unique: true, + filter: "\"DeletedAt\" IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_PublicSurveys_Status", + table: "PublicSurveys", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PublicSurveyResponseOptions"); + + migrationBuilder.DropTable( + name: "PublicSurveyOptions"); + + migrationBuilder.DropTable( + name: "PublicSurveyResponses"); + + migrationBuilder.DropTable( + name: "PublicSurveyQuestions"); + + migrationBuilder.DropTable( + name: "PublicSurveys"); + } + } +} diff --git a/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs b/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs index 97bb5f6b..8c88d64f 100644 --- a/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs @@ -1487,6 +1487,159 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("StatDataChangeSubscriptionTgMessages", (string)null); }); + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurvey", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Slug") + .IsUnique() + .HasFilter("\"DeletedAt\" IS NULL"); + + b.HasIndex("Status"); + + b.ToTable("PublicSurveys"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyOption", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId", "Order") + .IsUnique(); + + b.ToTable("PublicSurveyOptions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyQuestion", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AllowMultipleChoices") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PublicSurveyId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PublicSurveyId", "Order") + .IsUnique(); + + b.ToTable("PublicSurveyQuestions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponse", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("QuestionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("QuestionId", "UserId") + .IsUnique(); + + b.ToTable("PublicSurveyResponses"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponseOption", b => + { + b.Property("ResponseId") + .HasColumnType("uuid"); + + b.Property("OptionId") + .HasColumnType("uuid"); + + b.HasKey("ResponseId", "OptionId"); + + b.HasIndex("OptionId"); + + b.ToTable("PublicSurveyResponseOptions"); + }); + modelBuilder.Entity("Domain.Entities.Telegram.SalariesBotMessage", b => { b.Property("Id") @@ -2029,6 +2182,69 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SalarySubscription"); }); + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurvey", b => + { + b.HasOne("Domain.Entities.Users.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyOption", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurveyQuestion", "Question") + .WithMany("Options") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyQuestion", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurvey", "PublicSurvey") + .WithMany("Questions") + .HasForeignKey("PublicSurveyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PublicSurvey"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponse", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurveyQuestion", "Question") + .WithMany("Responses") + .HasForeignKey("QuestionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Question"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponseOption", b => + { + b.HasOne("Domain.Entities.Surveys.PublicSurveyOption", "Option") + .WithMany("ResponseOptions") + .HasForeignKey("OptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Surveys.PublicSurveyResponse", "Response") + .WithMany("SelectedOptions") + .HasForeignKey("ResponseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Option"); + + b.Navigation("Response"); + }); + modelBuilder.Entity("Domain.Entities.Telegram.TelegramUserSettings", b => { b.HasOne("Domain.Entities.Users.User", "User") @@ -2162,6 +2378,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("NextRecords"); }); + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurvey", b => + { + b.Navigation("Questions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyOption", b => + { + b.Navigation("ResponseOptions"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyQuestion", b => + { + b.Navigation("Options"); + + b.Navigation("Responses"); + }); + + modelBuilder.Entity("Domain.Entities.Surveys.PublicSurveyResponse", b => + { + b.Navigation("SelectedOptions"); + }); + modelBuilder.Entity("Domain.Entities.Users.User", b => { b.Navigation("Emails");