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
732 changes: 732 additions & 0 deletions api/Vote.Monitor.sln

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace Feature.CitizenReports.Comments;

public record CitizenReportCommentModel
{
public required Guid Id { get; init; }
public required Guid ElectionRoundId { get; init; }
public required Guid CitizenReportId { get; init; }
public required string Text { get; init; }
public required Guid CreatedBy { get; init; }
public required string CreatedByName { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? LastModifiedAt { get; init; }

public static CitizenReportCommentModel FromEntity(CitizenReportCommentAggregate comment)
=> new()
{
Id = comment.Id,
ElectionRoundId = comment.ElectionRoundId,
CitizenReportId = comment.CitizenReportId,
Text = comment.Text,
CreatedBy = comment.CreatedBy,
CreatedByName = string.Empty,
CreatedAt = comment.CreatedOn,
LastModifiedAt = comment.LastModifiedOn
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;

namespace Feature.CitizenReports.Comments;

public static class CitizenReportsCommentsInstaller
{
public static IServiceCollection AddCitizenReportsCommentsFeature(this IServiceCollection services)
{
return services;
}
}
50 changes: 50 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Create/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using Feature.CitizenReports.Comments.Specifications;
using Vote.Monitor.Domain.Entities.CitizenReportAggregate;

namespace Feature.CitizenReports.Comments.Create;

public class Endpoint(
IAuthorizationService authorizationService,
IReadRepository<CitizenReport> citizenReportRepository,
IRepository<CitizenReportCommentAggregate> repository)
: Endpoint<Request, Results<Ok<CitizenReportCommentModel>, NotFound>>
{
public override void Configure()
{
Post("/api/election-rounds/{electionRoundId}/citizen-reports/{citizenReportId}/comments");
DontAutoTag();
Options(x => x.WithTags("citizen-report-comments"));
Summary(s => { s.Summary = "Creates a comment for a citizen report."; });

Policies(PolicyNames.NgoAdminsOnly);
}

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

var citizenReportExists = await citizenReportRepository.AnyAsync(
new GetCitizenReportInElectionRoundSpecification(req.ElectionRoundId, req.CitizenReportId), ct);

if (!citizenReportExists)
{
return TypedResults.NotFound();
}

var comment = CitizenReportCommentAggregate.Create(
req.ElectionRoundId,
req.CitizenReportId,
req.Text);

await repository.AddAsync(comment, ct);

return TypedResults.Ok(CitizenReportCommentModel.FromEntity(comment));
}
}
14 changes: 14 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Create/Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Vote.Monitor.Core.Security;

namespace Feature.CitizenReports.Comments.Create;

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

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

public Guid CitizenReportId { get; set; }
public string Text { get; set; } = string.Empty;
}
12 changes: 12 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Create/Validator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Feature.CitizenReports.Comments.Create;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.NgoId).NotEmpty();
RuleFor(x => x.CitizenReportId).NotEmpty();
RuleFor(x => x.Text).NotEmpty().MaximumLength(10_000);
}
}
42 changes: 42 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Delete/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Feature.CitizenReports.Comments.Specifications;

namespace Feature.CitizenReports.Comments.Delete;

public class Endpoint(
IAuthorizationService authorizationService,
IRepository<CitizenReportCommentAggregate> repository)
: Endpoint<Request, Results<NoContent, NotFound>>
{
public override void Configure()
{
Delete("/api/election-rounds/{electionRoundId}/citizen-reports/{citizenReportId}/comments/{id}");
DontAutoTag();
Options(x => x.WithTags("citizen-report-comments"));
Summary(s => { s.Summary = "Deletes a citizen report comment. Only the author can delete it."; });

Policies(PolicyNames.NgoAdminsOnly);
}

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

var comment = await repository.FirstOrDefaultAsync(
new GetCommentByIdSpecification(req.ElectionRoundId, req.CitizenReportId, req.UserId, req.Id), ct);

if (comment is null)
{
return TypedResults.NotFound();
}

await repository.DeleteAsync(comment, ct);

return TypedResults.NoContent();
}
}
14 changes: 14 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Delete/Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Vote.Monitor.Core.Security;

namespace Feature.CitizenReports.Comments.Delete;

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

[FromClaim(ApplicationClaimTypes.UserId)]
public Guid UserId { get; set; }

public Guid CitizenReportId { get; set; }
public Guid Id { get; set; }
}
12 changes: 12 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Delete/Validator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Feature.CitizenReports.Comments.Delete;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.CitizenReportId).NotEmpty();
RuleFor(x => x.Id).NotEmpty();
}
}
5 changes: 5 additions & 0 deletions api/src/Feature.CitizenReports.Comments/EnableTesting.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Feature.CitizenReports.Comments.UnitTests")]
[assembly: InternalsVisibleTo("Vote.Monitor.Api.IntegrationTests")]
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Ardalis.SmartEnum.SystemTextJson" />
<PackageReference Include="FastEndpoints" />
<PackageReference Include="FastEndpoints.Security" />
<PackageReference Include="PolyJson" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Authorization.Policies\Authorization.Policies.csproj" />
<ProjectReference Include="..\Vote.Monitor.Domain\Vote.Monitor.Domain.csproj" />
</ItemGroup>
</Project>
11 changes: 11 additions & 0 deletions api/src/Feature.CitizenReports.Comments/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Global using directives

global using Authorization.Policies;
global using Authorization.Policies.Requirements;
global using FastEndpoints;
global using FluentValidation;
global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Http;
global using Microsoft.AspNetCore.Http.HttpResults;
global using Vote.Monitor.Domain.Repository;
global using CitizenReportCommentAggregate = Vote.Monitor.Domain.Entities.CitizenReportCommentAggregate.CitizenReportComment;
64 changes: 64 additions & 0 deletions api/src/Feature.CitizenReports.Comments/List/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Dapper;
using Vote.Monitor.Domain.ConnectionFactory;

namespace Feature.CitizenReports.Comments.List;

public class Endpoint(INpgsqlConnectionFactory dbConnectionFactory, IAuthorizationService authorizationService)
: Endpoint<Request, Results<Ok<Response>, NotFound>>
{
public override void Configure()
{
Get("/api/election-rounds/{electionRoundId}/citizen-reports/{citizenReportId}/comments");
DontAutoTag();
Options(x => x.WithTags("citizen-report-comments"));
Summary(s => { s.Summary = "Lists comments for a citizen report."; });

Policies(PolicyNames.NgoAdminsOnly);
}

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

var sql = """
SELECT
c."Id",
c."ElectionRoundId",
c."CitizenReportId",
c."Text",
c."CreatedBy",
COALESCE(u."DisplayName", '') AS "CreatedByName",
c."CreatedOn" AS "CreatedAt",
c."LastModifiedOn" AS "LastModifiedAt"
FROM "CitizenReportComments" c
INNER JOIN "CitizenReports" cr ON cr."Id" = c."CitizenReportId"
INNER JOIN "ElectionRounds" er ON er."Id" = cr."ElectionRoundId"
INNER JOIN "MonitoringNgos" mn ON mn."Id" = er."MonitoringNgoForCitizenReportingId"
LEFT JOIN "AspNetUsers" u ON u."Id" = c."CreatedBy"
WHERE c."ElectionRoundId" = @electionRoundId
AND c."CitizenReportId" = @citizenReportId
AND mn."NgoId" = @ngoId
AND mn."ElectionRoundId" = @electionRoundId
ORDER BY c."CreatedOn";
""";

var queryArgs = new
{
electionRoundId = req.ElectionRoundId, citizenReportId = req.CitizenReportId, ngoId = req.NgoId
};

List<CitizenReportCommentModel> comments;
using (var dbConnection = await dbConnectionFactory.GetOpenConnectionAsync(ct))
{
comments = (await dbConnection.QueryAsync<CitizenReportCommentModel>(sql, queryArgs)).ToList();
}

return TypedResults.Ok(new Response { Comments = comments });
}
}
13 changes: 13 additions & 0 deletions api/src/Feature.CitizenReports.Comments/List/Request.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Vote.Monitor.Core.Security;

namespace Feature.CitizenReports.Comments.List;

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

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

public Guid CitizenReportId { get; set; }
}
6 changes: 6 additions & 0 deletions api/src/Feature.CitizenReports.Comments/List/Response.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Feature.CitizenReports.Comments.List;

public record Response
{
public required List<CitizenReportCommentModel> Comments { get; init; }
}
11 changes: 11 additions & 0 deletions api/src/Feature.CitizenReports.Comments/List/Validator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Feature.CitizenReports.Comments.List;

public class Validator : Validator<Request>
{
public Validator()
{
RuleFor(x => x.ElectionRoundId).NotEmpty();
RuleFor(x => x.NgoId).NotEmpty();
RuleFor(x => x.CitizenReportId).NotEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Ardalis.Specification;
using Vote.Monitor.Domain.Entities.CitizenReportAggregate;

namespace Feature.CitizenReports.Comments.Specifications;

public sealed class GetCitizenReportInElectionRoundSpecification : Specification<CitizenReport>
{
public GetCitizenReportInElectionRoundSpecification(Guid electionRoundId, Guid citizenReportId)
{
Query.Where(x => x.Id == citizenReportId && x.ElectionRoundId == electionRoundId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Ardalis.Specification;

namespace Feature.CitizenReports.Comments.Specifications;

public sealed class GetCommentByIdSpecification : SingleResultSpecification<CitizenReportCommentAggregate>
{
public GetCommentByIdSpecification(Guid electionRoundId, Guid citizenReportId, Guid authorId, Guid id)
{
Query
.Where(x => x.ElectionRoundId == electionRoundId)
.Where(x => x.CitizenReportId == citizenReportId)
.Where(x => x.CreatedBy == authorId)
.Where(x => x.Id == id);
}
}
44 changes: 44 additions & 0 deletions api/src/Feature.CitizenReports.Comments/Update/Endpoint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using Feature.CitizenReports.Comments.Specifications;

namespace Feature.CitizenReports.Comments.Update;

public class Endpoint(
IAuthorizationService authorizationService,
IRepository<CitizenReportCommentAggregate> repository)
: Endpoint<Request, Results<Ok<CitizenReportCommentModel>, NotFound>>
{
public override void Configure()
{
Put("/api/election-rounds/{electionRoundId}/citizen-reports/{citizenReportId}/comments/{id}");
DontAutoTag();
Options(x => x.WithTags("citizen-report-comments"));
Summary(s => { s.Summary = "Updates a citizen report comment. Only the author can update it."; });

Policies(PolicyNames.NgoAdminsOnly);
}

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

var comment = await repository.FirstOrDefaultAsync(
new GetCommentByIdSpecification(req.ElectionRoundId, req.CitizenReportId, req.UserId, req.Id), ct);

if (comment is null)
{
return TypedResults.NotFound();
}

comment.UpdateText(req.Text);
await repository.UpdateAsync(comment, ct);

return TypedResults.Ok(CitizenReportCommentModel.FromEntity(comment));
}
}
Loading
Loading