Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/Vote.Monitor.sln
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Feature.Locations", "src\Fe
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Feature.Locations.UnitTests", "tests\Feature.Locations.UnitTests\Feature.Locations.UnitTests.csproj", "{6DC3922B-5AC8-4968-AE5C-557315576720}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Feature.Citizen.Guides", "src\Feature.Citizen.Guides\Feature.Citizen.Guides.csproj", "{A4842D5C-A8B6-4260-9071-A2431889EA24}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Feature.Citizen.Guides.UnitTests", "tests\Feature.Citizen.Guides.UnitTests\Feature.Citizen.Guides.UnitTests.csproj", "{4EFDD417-A568-4F39-BE44-0D97003EC42E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -428,6 +432,14 @@ Global
{6DC3922B-5AC8-4968-AE5C-557315576720}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6DC3922B-5AC8-4968-AE5C-557315576720}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6DC3922B-5AC8-4968-AE5C-557315576720}.Release|Any CPU.Build.0 = Release|Any CPU
{A4842D5C-A8B6-4260-9071-A2431889EA24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4842D5C-A8B6-4260-9071-A2431889EA24}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4842D5C-A8B6-4260-9071-A2431889EA24}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4842D5C-A8B6-4260-9071-A2431889EA24}.Release|Any CPU.Build.0 = Release|Any CPU
{4EFDD417-A568-4F39-BE44-0D97003EC42E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4EFDD417-A568-4F39-BE44-0D97003EC42E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4EFDD417-A568-4F39-BE44-0D97003EC42E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4EFDD417-A568-4F39-BE44-0D97003EC42E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -501,6 +513,8 @@ Global
{CCE23C74-3E33-40B7-A1E8-7672BAC5F814} = {3441EE1D-E3C6-45BE-A020-553816015081}
{54B0E751-AD8A-48F5-998C-E6A5701E3EF0} = {17945B3C-5A4C-4279-8022-65ABC606A510}
{6DC3922B-5AC8-4968-AE5C-557315576720} = {3441EE1D-E3C6-45BE-A020-553816015081}
{A4842D5C-A8B6-4260-9071-A2431889EA24} = {17945B3C-5A4C-4279-8022-65ABC606A510}
{4EFDD417-A568-4F39-BE44-0D97003EC42E} = {3441EE1D-E3C6-45BE-A020-553816015081}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {50C20C9F-F2AF-45D8-994A-06661772B31C}
Expand Down
1 change: 1 addition & 0 deletions api/src/Authorization.Policies/PoliciesInstaller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public static IServiceCollection AddAuthorizationPolicies(this IServiceCollectio
services.AddScoped<IAuthorizationHandler, MonitoringNgoAdminOrObserverAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, MonitoringObserverAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, NgoAdminAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, CitizenReportingNgoAdminAuthorizationHandler>();

services.AddAuthorization(options =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Authorization.Policies.Requirements;
using Authorization.Policies.Specifications;
using Vote.Monitor.Domain.Entities.ElectionRoundAggregate;

namespace Authorization.Policies.RequirementHandlers;

internal class CitizenReportingNgoAdminAuthorizationHandler(
ICurrentUserProvider currentUserProvider,
ICurrentUserRoleProvider currentUserRoleProvider,
IReadRepository<ElectionRound> electionRoundRepository)
: AuthorizationHandler<CitizenReportingNgoAdminRequirement>
{
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
CitizenReportingNgoAdminRequirement requirement)
{
if (!currentUserRoleProvider.IsNgoAdmin())
{
context.Fail();
return;
}

var ngoId = currentUserProvider.GetNgoId();
if (ngoId is null)
{
context.Fail();
return;
}

var getMonitoringNgoSpecification =
new GetCitizenReportingMonitoringNgoSpecification(requirement.ElectionRoundId, ngoId.Value);
var result = await electionRoundRepository.FirstOrDefaultAsync(getMonitoringNgoSpecification);

if (result is null)
{
context.Fail();
return;
}

if (result.ElectionRoundStatus == ElectionRoundStatus.Archived
|| result.NgoStatus == NgoStatus.Deactivated
|| result.MonitoringNgoStatus == MonitoringNgoStatus.Suspended)
{
context.Fail();
return;
}

context.Succeed(requirement);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext
}

var ngoId = currentUserProvider.GetNgoId();
var getMonitoringNgoSpecification = new GetMonitoringNgoSpecification(adminRequirement.ElectionRoundId, ngoId!.Value);
if (ngoId is null)
{
context.Fail();
return;
}


var getMonitoringNgoSpecification = new GetMonitoringNgoSpecification(adminRequirement.ElectionRoundId, ngoId.Value);
var result = await monitoringNgoRepository.FirstOrDefaultAsync(getMonitoringNgoSpecification);

if (result is null)
Expand All @@ -38,4 +45,4 @@ protected override async Task HandleRequirementAsync(AuthorizationHandlerContext

context.Succeed(adminRequirement);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Authorization.Policies.Requirements;

public class CitizenReportingNgoAdminRequirement(Guid electionRoundId) : IAuthorizationRequirement
{
public Guid ElectionRoundId { get; } = electionRoundId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
public class MonitoringNgoAdminRequirement(Guid electionRoundId) : IAuthorizationRequirement
{
public Guid ElectionRoundId { get; } = electionRoundId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Ardalis.Specification;
using Vote.Monitor.Domain.Entities.ElectionRoundAggregate;

namespace Authorization.Policies.Specifications;

internal sealed class GetCitizenReportingMonitoringNgoSpecification : SingleResultSpecification<ElectionRound, MonitoringNgoView>
{
public GetCitizenReportingMonitoringNgoSpecification(Guid electionRoundId, Guid ngoId)
{
Query
.Include(x => x.MonitoringNgoForCitizenReporting)
.ThenInclude(x => x.Ngo)
.Where(x => x.CitizenReportingEnabled && x.Id == electionRoundId &&
x.MonitoringNgoForCitizenReporting.NgoId == ngoId)
.AsNoTracking();

Query.Select(x => new MonitoringNgoView
{
ElectionRoundId = x.Id,
ElectionRoundStatus = x.Status,
NgoId = x.MonitoringNgoForCitizenReporting.NgoId,
NgoStatus = x.MonitoringNgoForCitizenReporting.Ngo.Status,
MonitoringNgoId = x.MonitoringNgoForCitizenReporting.Id,
MonitoringNgoStatus = x.MonitoringNgoForCitizenReporting.Status
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ public GetMonitoringNgoSpecification(Guid electionRoundId, Guid ngoId)
MonitoringNgoStatus = x.Status
});
}
}
}
23 changes: 23 additions & 0 deletions api/src/Feature.Citizen.Guides/CitizenGuideModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
using Ardalis.SmartEnum.SystemTextJson;
using Vote.Monitor.Domain.Entities.CitizenGuideAggregate;

namespace Feature.Citizen.Guides;

public record CitizenGuideModel
{
public required Guid Id { get; init; }
public string Title { get; init; } = string.Empty;
public string? FileName { get; init; } = string.Empty;
public string? MimeType { get; init; } = string.Empty;
public string? PresignedUrl { get; init; } = string.Empty;
public int? UrlValidityInSeconds { get; init; }
public string? WebsiteUrl { get; init; }
public string? Text { get; init; }

[JsonConverter(typeof(SmartEnumNameConverter<CitizenGuideType, string>))]
public CitizenGuideType GuideType { get; init; }

public DateTime CreatedOn { get; init; }
public string CreatedBy { get; init; }
}
11 changes: 11 additions & 0 deletions api/src/Feature.Citizen.Guides/CitizenGuidesFeatureInstaller.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;

namespace Feature.Citizen.Guides;

public static class CitizenGuidesFeatureInstaller
{
public static IServiceCollection AddCitizenGuidesFeature(this IServiceCollection services)
{
return services;
}
}
103 changes: 103 additions & 0 deletions api/src/Feature.Citizen.Guides/Create/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Net;
using Authorization.Policies;
using Authorization.Policies.Requirements;
using Microsoft.AspNetCore.Authorization;
using Vote.Monitor.Core.Services.FileStorage.Contracts;
using Vote.Monitor.Domain.Entities.CitizenGuideAggregate;

namespace Feature.Citizen.Guides.Create;

public class Endpoint(
IAuthorizationService authorizationService,
IRepository<CitizenGuideAggregate> repository,
IFileStorageService fileStorageService)
: Endpoint<Request, Results<Ok<CitizenGuideModel>, NotFound, StatusCodeHttpResult>>
{
public override void Configure()
{
Post("/api/election-rounds/{electionRoundId}/citizen-guides");
DontAutoTag();
Options(x => x.WithTags("citizen-guides"));
AllowFileUploads();
Policies(PolicyNames.NgoAdminsOnly);
}

public override async Task<Results<Ok<CitizenGuideModel>, NotFound, StatusCodeHttpResult>> ExecuteAsync(
Request req, CancellationToken ct)
{
var requirement = new CitizenReportingNgoAdminRequirement(req.ElectionRoundId);
var authorizationResult = await authorizationService.AuthorizeAsync(User, requirement);
if (!authorizationResult.Succeeded)
{
return TypedResults.NotFound();
}

CitizenGuideAggregate? citizenGuide = null;
CitizenGuideModel? citizenGuideModel = null;
if (req.GuideType == CitizenGuideType.Document)
{
var uploadPath = $"elections/{req.ElectionRoundId}/citizen-guides";

citizenGuide = CitizenGuide.NewDocumentGuide(req.ElectionRoundId,
req.Title,
req.Attachment!.FileName,
uploadPath,
req.Attachment.ContentType);

var uploadResult = await fileStorageService.UploadFileAsync(uploadPath,
fileName: citizenGuide.UploadedFileName!,
req.Attachment.OpenReadStream(),
ct);

if (uploadResult is UploadFileResult.Failed)
{
return TypedResults.StatusCode((int)HttpStatusCode.InternalServerError);
}

var result = uploadResult as UploadFileResult.Ok;
citizenGuideModel = new CitizenGuideModel
{
Title = citizenGuide.Title,
FileName = citizenGuide.FileName!,
PresignedUrl = result!.Url,
MimeType = citizenGuide.MimeType!,
UrlValidityInSeconds = result.UrlValidityInSeconds,

Check warning

Code scanning / CodeQL

Dereferenced variable may be null

Variable [result](1) may be null at this access because of [this](2) assignment.
Id = citizenGuide.Id,
GuideType = citizenGuide.GuideType
};
}

if (req.GuideType == CitizenGuideType.Website)
{
citizenGuide = CitizenGuide.NewWebsiteGuide(req.ElectionRoundId,
req.Title,
new Uri(req.WebsiteUrl!));

citizenGuideModel = new CitizenGuideModel
{
Id = citizenGuide.Id,
Title = citizenGuide.Title,
WebsiteUrl = citizenGuide.WebsiteUrl,
GuideType = citizenGuide.GuideType
};
}

if (req.GuideType == CitizenGuideType.Text)
{
citizenGuide = CitizenGuide.NewTextGuide(req.ElectionRoundId,
req.Title,
req.Text!);

citizenGuideModel = new CitizenGuideModel
{
Id = citizenGuide.Id,
Title = citizenGuide.Title,
Text = citizenGuide.Text,
GuideType = citizenGuide.GuideType
};
}

await repository.AddAsync(citizenGuide!, ct);
return TypedResults.Ok(citizenGuideModel!);
}
}
22 changes: 22 additions & 0 deletions api/src/Feature.Citizen.Guides/Create/Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using Vote.Monitor.Core.Security;
using Vote.Monitor.Domain.Entities.CitizenGuideAggregate;

namespace Feature.Citizen.Guides.Create;

public class Request
{
public Guid ElectionRoundId { get; set; }

[FromClaim(ApplicationClaimTypes.NgoId)]
public Guid NgoId { get; set; }
public string Title { get; set; }

public CitizenGuideType GuideType { get; set; }

[FromForm]
public IFormFile? Attachment { get; set; }

public string? WebsiteUrl { get; set; }
public string? Text { get; set; }
}
28 changes: 28 additions & 0 deletions api/src/Feature.Citizen.Guides/Create/Validator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Vote.Monitor.Core.Validators;
using Vote.Monitor.Domain.Entities.CitizenGuideAggregate;

namespace Feature.Citizen.Guides.Create;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);

RuleFor(x => x.Attachment)
.NotEmpty()!
.FileSmallerThan(50 * 1024 * 1024) // 50 MB upload limit
.When(x => x.GuideType == CitizenGuideType.Document);

RuleFor(x => x.WebsiteUrl)
.NotEmpty()!
.MaximumLength(2048)
.IsValidUri()
.When(x => x.GuideType == CitizenGuideType.Website);

RuleFor(x => x.Text)
.NotEmpty()!
.When(x => x.GuideType == CitizenGuideType.Text);
}
}
Loading