From e08513aebe047284c1380b0d2fd8fa042bbb35f6 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Fri, 22 Nov 2024 17:28:43 +0200 Subject: [PATCH 1/3] Rework header --- .../Create/Endpoint.cs | 8 +- .../ElectionRoundModel.cs | 9 +- .../Get/Endpoint.cs | 107 +++++++++++++++++- .../Monitoring/Endpoint.cs | 19 +++- .../Monitoring/NgoElectionRoundView.cs | 18 --- .../Monitoring/Result.cs | 2 +- .../GetElectionRoundByIdSpecification.cs | 33 ------ .../GetElectionsSpecification.cs | 26 ----- .../GetObserverElectionSpecification.cs | 6 +- .../ListElectionRoundsSpecification.cs | 6 +- .../Endpoints/GetEndpointTests.cs | 62 ---------- .../GetElectionRoundByIdSpecificationTests.cs | 26 ----- .../ElectionRounds/GetMonitoringTests.cs | 10 +- web/src/common/types.ts | 13 --- .../DataSourceSwitcher/DataSourceSwitcher.tsx | 9 +- web/src/components/layout/Header/Header.tsx | 38 ++++--- web/src/context/election-round.store.tsx | 33 ++---- .../components/Dashboard/Dashboard.tsx | 20 ++-- .../hooks/election-event-hooks.ts | 1 - .../election-event/models/election-event.ts | 6 +- .../filtering/components/FormTypeFilter.tsx | 8 +- .../forms/components/Dashboard/CreateForm.tsx | 5 +- .../forms/components/Dashboard/Dashboard.tsx | 14 ++- .../Dashboard/EditFormAccessDialog.tsx | 1 - .../components/EditForm/EditFormDetails.tsx | 8 +- .../EditFormTranslationDetails.tsx | 7 +- .../components/Dashboard/Dashboard.tsx | 7 +- .../components/Dashboard/Dashboard.tsx | 11 +- 28 files changed, 228 insertions(+), 285 deletions(-) delete mode 100644 api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/NgoElectionRoundView.cs delete mode 100644 api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionRoundByIdSpecification.cs delete mode 100644 api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionsSpecification.cs delete mode 100644 api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Endpoints/GetEndpointTests.cs delete mode 100644 api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Specifications/GetElectionRoundByIdSpecificationTests.cs diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Create/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Create/Endpoint.cs index d047068d2..fdb3a91f0 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Create/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Create/Endpoint.cs @@ -58,7 +58,11 @@ public override async Task, Conflict))] public required ElectionRoundStatus Status { get; init; } public required DateTime CreatedOn { get; init; } public required DateTime? LastModifiedOn { get; init; } -} \ No newline at end of file + + public required bool IsMonitoringNgoForCitizenReporting { get; init; } + public required bool IsCoalitionLeader { get; init; } + + public required Guid? CoalitionId { get; init; } + public required string? CoalitionName { get; init; } +} diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Get/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Get/Endpoint.cs index 925f42e7d..c116afe51 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Get/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Get/Endpoint.cs @@ -1,6 +1,13 @@ -namespace Vote.Monitor.Api.Feature.ElectionRound.Get; +using Microsoft.EntityFrameworkCore; +using Vote.Monitor.Core.Services.Security; +using Vote.Monitor.Domain; -public class Endpoint(IReadRepository repository) +namespace Vote.Monitor.Api.Feature.ElectionRound.Get; + +public class Endpoint( + VoteMonitorContext context, + ICurrentUserProvider userProvider, + ICurrentUserRoleProvider roleProvider) : Endpoint, NotFound>> { public override void Configure() @@ -9,10 +16,100 @@ public override void Configure() Policies(PolicyNames.AdminsOnly); } - public override async Task, NotFound>> ExecuteAsync(Request req, CancellationToken ct) + public override async Task, NotFound>> ExecuteAsync(Request req, + CancellationToken ct) + { + if (roleProvider.IsPlatformAdmin()) + { + return await GetElectionRoundAsPlatformAdmin(req, ct); + } + + return await GetElectionRoundAsNgoAdmin(req, ct); + } + + private async Task, NotFound>> GetElectionRoundAsPlatformAdmin(Request req, + CancellationToken ct) + { + var electionRound = await context.ElectionRounds.Where(x => x.Id == req.Id) + .Include(x => x.MonitoringNgos) + .ThenInclude(x => x.Ngo) + .Include(x => x.MonitoringNgos) + .ThenInclude(x => x.MonitoringObservers) + .Include(x => x.Country) + .AsSplitQuery() + .Select(electionRound => new ElectionRoundModel + { + Id = electionRound.Id, + CountryId = electionRound.CountryId, + CountryIso2 = electionRound.Country.Iso2, + CountryIso3 = electionRound.Country.Iso3, + CountryName = electionRound.Country.Name, + CountryFullName = electionRound.Country.FullName, + CountryNumericCode = electionRound.Country.NumericCode, + Title = electionRound.Title, + EnglishTitle = electionRound.EnglishTitle, + Status = electionRound.Status, + StartDate = electionRound.StartDate, + LastModifiedOn = electionRound.LastModifiedOn, + CreatedOn = electionRound.CreatedOn, + CoalitionId = null, + CoalitionName = null, + IsCoalitionLeader = false, + IsMonitoringNgoForCitizenReporting = false, + }) + .FirstOrDefaultAsync(ct); + + if (electionRound is null) + { + return TypedResults.NotFound(); + } + + return TypedResults.Ok(electionRound); + } + + private async Task, NotFound>> GetElectionRoundAsNgoAdmin(Request req, + CancellationToken ct) { - var specification = new GetElectionRoundByIdSpecification(req.Id); - var electionRound = await repository.SingleOrDefaultAsync(specification, ct); + var ngoId = userProvider.GetNgoId()!.Value; + + var electionRound = await context.MonitoringNgos + .Include(x => x.ElectionRound) + .ThenInclude(x => x.MonitoringNgoForCitizenReporting) + .Where(x => x.NgoId == ngoId) + .Where(x => x.ElectionRoundId == req.Id) + .OrderBy(x => x.ElectionRound.StartDate) + .Select(x => new ElectionRoundModel + { + Id = x.ElectionRound.Id, + CountryId = x.ElectionRound.CountryId, + CountryIso2 = x.ElectionRound.Country.Iso2, + CountryIso3 = x.ElectionRound.Country.Iso3, + CountryName = x.ElectionRound.Country.Name, + CountryFullName = x.ElectionRound.Country.FullName, + CountryNumericCode = x.ElectionRound.Country.NumericCode, + Title = x.ElectionRound.Title, + EnglishTitle = x.ElectionRound.EnglishTitle, + Status = x.ElectionRound.Status, + StartDate = x.ElectionRound.StartDate, + LastModifiedOn = x.ElectionRound.LastModifiedOn, + CreatedOn = x.ElectionRound.CreatedOn, + IsMonitoringNgoForCitizenReporting = x.ElectionRound.CitizenReportingEnabled && + x.ElectionRound.MonitoringNgoForCitizenReporting.NgoId == + ngoId, + IsCoalitionLeader = + context.Coalitions.Any(c => c.Leader.NgoId == ngoId && c.ElectionRoundId == x.ElectionRoundId), + CoalitionName = context.Coalitions + .Where(c => + c.Memberships.Any(m => m.MonitoringNgoId == x.Id) && c.ElectionRoundId == x.ElectionRoundId) + .Select(c => c.Name) + .FirstOrDefault(), + CoalitionId = context.Coalitions + .Where(c => + c.Memberships.Any(m => m.MonitoringNgoId == x.Id) && c.ElectionRoundId == x.ElectionRoundId) + .Select(c => c.Id) + .FirstOrDefault() + }) + .FirstOrDefaultAsync(ct); if (electionRound is null) { diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs index ac082d6dd..0233600af 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Vote.Monitor.Core.Services.Security; using Vote.Monitor.Domain; namespace Vote.Monitor.Api.Feature.ElectionRound.Monitoring; @@ -25,23 +26,29 @@ public override async Task> ExecuteAsync(Request req, CancellationTok var electionRounds = await context.MonitoringNgos .Include(x => x.ElectionRound) .ThenInclude(x => x.MonitoringNgoForCitizenReporting) + .Include(x=>x.ElectionRound.Country) .Where(x => x.NgoId == req.NgoId) .OrderBy(x => x.ElectionRound.StartDate) - .Select(x => new NgoElectionRoundView + .Select(x => new ElectionRoundModel { - MonitoringNgoId = x.Id, - ElectionRoundId = x.ElectionRoundId, + Id = x.ElectionRound.Id, + CountryId = x.ElectionRound.CountryId, + CountryIso2 = x.ElectionRound.Country.Iso2, + CountryIso3 = x.ElectionRound.Country.Iso3, + CountryName = x.ElectionRound.Country.Name, + CountryFullName = x.ElectionRound.Country.FullName, + CountryNumericCode = x.ElectionRound.Country.NumericCode, Title = x.ElectionRound.Title, EnglishTitle = x.ElectionRound.EnglishTitle, + Status = x.ElectionRound.Status, StartDate = x.ElectionRound.StartDate, - Country = x.ElectionRound.Country.FullName, - CountryId = x.ElectionRound.CountryId, + LastModifiedOn = x.ElectionRound.LastModifiedOn, + CreatedOn = x.ElectionRound.CreatedOn, IsMonitoringNgoForCitizenReporting = x.ElectionRound.CitizenReportingEnabled && x.ElectionRound.MonitoringNgoForCitizenReporting.NgoId == req.NgoId, IsCoalitionLeader = context.Coalitions.Any(c => c.Leader.NgoId == req.NgoId && c.ElectionRoundId == x.ElectionRoundId), - Status = x.ElectionRound.Status, CoalitionName = context.Coalitions .Where(c => c.Memberships.Any(m => m.MonitoringNgoId == x.Id) && c.ElectionRoundId == x.ElectionRoundId) diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/NgoElectionRoundView.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/NgoElectionRoundView.cs deleted file mode 100644 index 5d2adb7ba..000000000 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/NgoElectionRoundView.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Vote.Monitor.Api.Feature.ElectionRound.Monitoring; - -public class NgoElectionRoundView -{ - public Guid MonitoringNgoId { get; set; } - public Guid ElectionRoundId { get; set; } - public string Title { get; set; } - public string EnglishTitle { get; set; } - public DateOnly StartDate { get; set; } - public string Country { get; set; } - public Guid CountryId { get; set; } - public bool IsMonitoringNgoForCitizenReporting { get; set; } - public bool IsCoalitionLeader { get; set; } - public ElectionRoundStatus Status { get; set; } - - public Guid? CoalitionId { get; set; } - public string? CoalitionName { get; set; } -} diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Result.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Result.cs index d50ac717c..f19cfbc99 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Result.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Result.cs @@ -2,5 +2,5 @@ public class Result { - public List ElectionRounds { get; set; } + public List ElectionRounds { get; set; } } diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionRoundByIdSpecification.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionRoundByIdSpecification.cs deleted file mode 100644 index 2f64d501b..000000000 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionRoundByIdSpecification.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace Vote.Monitor.Api.Feature.ElectionRound.Specifications; - -public sealed class GetElectionRoundByIdSpecification : SingleResultSpecification -{ - public GetElectionRoundByIdSpecification(Guid id) - { - Query - .Where(x => x.Id == id) - .Include(x => x.MonitoringNgos) - .ThenInclude(x => x.Ngo) - .Include(x => x.MonitoringNgos) - .ThenInclude(x => x.MonitoringObservers) - .Include(x => x.Country) - .AsSplitQuery(); - - Query.Select(electionRound => new ElectionRoundModel - { - Id = electionRound.Id, - CountryId = electionRound.CountryId, - CountryIso2 = electionRound.Country.Iso2, - CountryIso3 = electionRound.Country.Iso3, - CountryName = electionRound.Country.Name, - CountryFullName = electionRound.Country.FullName, - CountryNumericCode = electionRound.Country.NumericCode, - Title = electionRound.Title, - EnglishTitle = electionRound.EnglishTitle, - Status = electionRound.Status, - StartDate = electionRound.StartDate, - LastModifiedOn = electionRound.LastModifiedOn, - CreatedOn = electionRound.CreatedOn - }); - } -} diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionsSpecification.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionsSpecification.cs deleted file mode 100644 index 3bb2d0c93..000000000 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetElectionsSpecification.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Vote.Monitor.Api.Feature.ElectionRound.Specifications; - -public sealed class GetElectionsSpecification : Specification -{ - public GetElectionsSpecification() - { - Query.Where(x => x.Status != ElectionRoundStatus.Archived); - - Query.Select(x => new ElectionRoundModel - { - Id = x.Id, - Title = x.Title, - EnglishTitle = x.EnglishTitle, - StartDate = x.StartDate, - Status = x.Status, - CreatedOn = x.CreatedOn, - LastModifiedOn = x.LastModifiedOn, - CountryIso2 = x.Country.Iso2, - CountryIso3 = x.Country.Iso3, - CountryName = x.Country.Name, - CountryFullName = x.Country.FullName, - CountryNumericCode = x.Country.NumericCode, - CountryId = x.CountryId - }); - } -} diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetObserverElectionSpecification.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetObserverElectionSpecification.cs index 6a27bb9a1..460dbaa31 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetObserverElectionSpecification.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/GetObserverElectionSpecification.cs @@ -24,7 +24,11 @@ public GetObserverElectionSpecification(Guid observerId) CountryIso3 = x.Country.Iso3, CountryName = x.Country.Name, CountryFullName = x.Country.FullName, - CountryNumericCode = x.Country.NumericCode + CountryNumericCode = x.Country.NumericCode, + CoalitionId = null, + CoalitionName = null, + IsCoalitionLeader = false, + IsMonitoringNgoForCitizenReporting = false }); } } diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/ListElectionRoundsSpecification.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/ListElectionRoundsSpecification.cs index 69ba5d4f8..7f0794707 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/ListElectionRoundsSpecification.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Specifications/ListElectionRoundsSpecification.cs @@ -27,7 +27,11 @@ public ListElectionRoundsSpecification(List.Request request) CountryIso3 = x.Country.Iso3, CountryName = x.Country.Name, CountryFullName = x.Country.FullName, - CountryNumericCode = x.Country.NumericCode + CountryNumericCode = x.Country.NumericCode, + CoalitionId = null, + CoalitionName = null, + IsCoalitionLeader = false, + IsMonitoringNgoForCitizenReporting = false }); } } diff --git a/api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Endpoints/GetEndpointTests.cs b/api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Endpoints/GetEndpointTests.cs deleted file mode 100644 index a4842aa27..000000000 --- a/api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Endpoints/GetEndpointTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -namespace Vote.Monitor.Api.Feature.ElectionRound.UnitTests.Endpoints; - -public class GetEndpointTests -{ - [Fact] - public async Task Should_ReturnElectionRound_WhenExists() - { - // Arrange - var electionRoundModel = new ElectionRoundModel - { - Id = Guid.NewGuid(), - Title = "A title", - StartDate = new DateOnly(2024, 01, 02), - EnglishTitle = "An english title", - Status = ElectionRoundStatus.NotStarted, - CountryId = CountriesList.MD.Id, - CountryIso2 = CountriesList.MD.Iso2, - CountryIso3 = CountriesList.MD.Iso3, - CountryName = CountriesList.MD.Name, - CountryFullName = CountriesList.MD.FullName, - CountryNumericCode = CountriesList.MD.NumericCode, - CreatedOn = DateTime.UtcNow.AddHours(-30), - LastModifiedOn = DateTime.UtcNow.AddHours(-15) - }; - - var repository = Substitute.For>(); - repository - .SingleOrDefaultAsync(Arg.Any()) - .Returns(electionRoundModel); - - var endpoint = Factory.Create(repository); - - // Act - var request = new Get.Request { Id = electionRoundModel.Id }; - var result = await endpoint.ExecuteAsync(request, default); - - // Assert - result - .Should().BeOfType, NotFound>>() - .Which - .Result.Should().BeOfType>() - .Which.Value.Should().BeEquivalentTo(electionRoundModel); - } - - [Fact] - public async Task ShouldReturnNotFound_WhenElectionRoundNotFound() - { - // Arrange - var repository = Substitute.For>(); - var endpoint = Factory.Create(repository); - - // Act - var request = new Get.Request { Id = Guid.NewGuid() }; - var result = await endpoint.ExecuteAsync(request, default); - - // Assert - result - .Should().BeOfType, NotFound>>() - .Which - .Result.Should().BeOfType(); - } -} diff --git a/api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Specifications/GetElectionRoundByIdSpecificationTests.cs b/api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Specifications/GetElectionRoundByIdSpecificationTests.cs deleted file mode 100644 index ee5c5df11..000000000 --- a/api/tests/Vote.Monitor.Api.Feature.ElectionRound.UnitTests/Specifications/GetElectionRoundByIdSpecificationTests.cs +++ /dev/null @@ -1,26 +0,0 @@ -namespace Vote.Monitor.Api.Feature.ElectionRound.UnitTests.Specifications; - -public class GetElectionRoundByIdSpecificationTests -{ - [Fact] - public void ShouldMatch_NonArchivedElectionRounds() - { - // Arrange - var electionRoundId = Guid.NewGuid(); - var electionRound = new ElectionRoundAggregateFaker(id: electionRoundId).Generate(); - - List testCollection = - [ - electionRound, - .. new ElectionRoundAggregateFaker().Generate(100) - ]; - - // Act - var spec = new GetElectionRoundByIdSpecification(electionRoundId); - var result = spec.Evaluate(testCollection).ToList(); - - // Assert - result.Should().HaveCount(1); - result.Should().Contain(x => x.Id == electionRoundId); - } -} diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/ElectionRounds/GetMonitoringTests.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/ElectionRounds/GetMonitoringTests.cs index e328f2298..71b28d341 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/ElectionRounds/GetMonitoringTests.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/ElectionRounds/GetMonitoringTests.cs @@ -46,14 +46,14 @@ public void ShouldReturnCorrectElectionRoundDetails() alfaNgoElectionRounds .ElectionRounds - .First(x => x.ElectionRoundId == electionRoundAId) + .First(x => x.Id == electionRoundAId) .IsCoalitionLeader .Should() .BeTrue(); alfaNgoElectionRounds .ElectionRounds - .First(x => x.ElectionRoundId == electionRoundCId) + .First(x => x.Id == electionRoundCId) .IsCoalitionLeader .Should() .BeFalse(); @@ -64,21 +64,21 @@ public void ShouldReturnCorrectElectionRoundDetails() betaNgoElectionRounds .ElectionRounds - .First(x => x.ElectionRoundId == electionRoundAId) + .First(x => x.Id == electionRoundAId) .IsCoalitionLeader .Should() .BeFalse(); betaNgoElectionRounds .ElectionRounds - .First(x => x.ElectionRoundId == electionRoundBId) + .First(x => x.Id == electionRoundBId) .IsCoalitionLeader .Should() .BeFalse(); betaNgoElectionRounds .ElectionRounds - .First(x => x.ElectionRoundId == electionRoundCId) + .First(x => x.Id == electionRoundCId) .IsCoalitionLeader .Should() .BeFalse(); diff --git a/web/src/common/types.ts b/web/src/common/types.ts index d4f3cbcbf..e62c90ca6 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -186,19 +186,6 @@ export enum ElectionRoundStatus { Archived = 'Archived', } -export type ElectionRoundMonitoring = { - monitoringNgoId: string; - electionRoundId: string; - title: string; - englishTitle: string; - startDate: string; - country: string; - countryId: string; - isMonitoringNgoForCitizenReporting: boolean; - isCoalitionLeader: boolean; - status: ElectionRoundStatus; -}; - export type LevelNode = { id: number; name: string; diff --git a/web/src/components/DataSourceSwitcher/DataSourceSwitcher.tsx b/web/src/components/DataSourceSwitcher/DataSourceSwitcher.tsx index 4d479986e..fff967993 100644 --- a/web/src/components/DataSourceSwitcher/DataSourceSwitcher.tsx +++ b/web/src/components/DataSourceSwitcher/DataSourceSwitcher.tsx @@ -7,11 +7,14 @@ import { Label } from '../ui/label'; import { Switch } from '../ui/switch'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { omit } from '../../lib/utils'; +import { useElectionRoundDetails } from '@/features/election-event/hooks/election-event-hooks'; export function DataSourceSwitcher(): FunctionComponent { - const isCoalitionLeader = useCurrentElectionRoundStore((s) => s.isCoalitionLeader); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); + const navigate = useNavigate(); - + const search: any = useSearch({ strict: false, }); @@ -49,7 +52,7 @@ export function DataSourceSwitcher(): FunctionComponent { setIsCoalition((search.dataSource ?? dataSource) === DataSources.Coalition); }, [search.dataSource]); - return isCoalitionLeader ? ( + return electionRound?.isCoalitionLeader ? (
{ const { userRole, signOut } = useContext(AuthContext); const navigate = useNavigate(); - const [selectedElectionRound, setSelectedElection] = useState(); + const [selectedElectionRound, setSelectedElection] = useState(); const router = useRouter(); const { setCurrentElectionRoundId, - setIsMonitoringNgoForCitizenReporting, currentElectionRoundId, - setIsCoalitionLeader, } = useCurrentElectionRoundStore((s) => s); - const handleSelectElectionRound = async (electionRound?: ElectionRoundMonitoring): Promise => { - if (electionRound && selectedElectionRound?.electionRoundId != electionRound.electionRoundId) { + const handleSelectElectionRound = async (electionRound?: ElectionEvent): Promise => { + if (electionRound && selectedElectionRound?.id != electionRound.id ) { setSelectedElection(electionRound); - setCurrentElectionRoundId(electionRound.electionRoundId); - setIsMonitoringNgoForCitizenReporting(electionRound.isMonitoringNgoForCitizenReporting); - setIsCoalitionLeader(electionRound.isCoalitionLeader); + setCurrentElectionRoundId(electionRound.id); sleep(1); @@ -83,7 +80,11 @@ const Header = (): FunctionComponent => { const { status, data: electionRounds } = useQuery({ queryKey: electionRoundKeys.all, queryFn: async () => { - const response = await authApi.get<{ electionRounds: ElectionRoundMonitoring[] }>('/election-rounds:monitoring'); + const response = await authApi.get<{ electionRounds: ElectionEvent[] }>('/election-rounds:monitoring'); + + (response.data.electionRounds ?? []).forEach((er) => { + queryClient.setQueryData(electionRoundKeys.detail(er.id), er); + }); return response.data.electionRounds ?? []; }, staleTime: 0, @@ -92,7 +93,8 @@ const Header = (): FunctionComponent => { useEffect(() => { if (!!electionRounds) { - const electionRound = electionRounds.find((x) => x.electionRoundId === currentElectionRoundId); + debugger; + const electionRound = electionRounds.find((x) => x.id === currentElectionRoundId); handleSelectElectionRound(electionRound ?? electionRounds[0]); } }, [electionRounds]); @@ -152,7 +154,7 @@ const Header = (): FunctionComponent => { ) : ( - +
{selectedElectionRound?.title} @@ -163,17 +165,17 @@ const Header = (): FunctionComponent => { { - const electionRound = electionRounds?.find((er) => er.electionRoundId === value); + const electionRound = electionRounds?.find((er) => er.id === value); handleSelectElectionRound(electionRound); }}> Upcomming elections {activeElections?.map((electionRound) => ( + key={electionRound.id} + value={electionRound.id}>
{electionRound?.status === ElectionRoundStatus.NotStarted ? ( @@ -191,8 +193,8 @@ const Header = (): FunctionComponent => { Archived elections {archivedElections?.map((electionRound) => ( + key={electionRound.id} + value={electionRound.id}>
diff --git a/web/src/context/election-round.store.tsx b/web/src/context/election-round.store.tsx index b951b5cb6..9e40f9395 100644 --- a/web/src/context/election-round.store.tsx +++ b/web/src/context/election-round.store.tsx @@ -2,16 +2,16 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { createContext, PropsWithChildren, useContext, useId, useRef } from 'react'; -import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useStoreWithEqualityFn } from 'zustand/traditional'; // can't see how to properly type things without copy/pasting from zustand -import type { StoreApi } from "zustand"; +import type { StoreApi } from 'zustand'; export type ExtractState = S extends { getState: () => infer T; } ? T : never; -export type ReadonlyStoreApi = Pick, "getState" | "subscribe">; +export type ReadonlyStoreApi = Pick, 'getState' | 'subscribe'>; export type WithReact> = S & { getServerState?: () => ExtractState; }; @@ -19,17 +19,10 @@ export type WithReact> = S & { // not copied from zustand source export type ZustandStore = WithReact>; - export type CurrentElectionRoundState = { currentElectionRoundId: string; setCurrentElectionRoundId(electionRoundId: string): void; - - isMonitoringNgoForCitizenReporting: boolean; - setIsMonitoringNgoForCitizenReporting(isMonitoringNgoForCitizenReporting: boolean): void; - - isCoalitionLeader: boolean; - setIsCoalitionLeader(isCoalitionLeader: boolean): void; -} +}; export type CurrentElectionRoundStoreType = ZustandStore; @@ -44,30 +37,20 @@ export const CurrentElectionRoundStoreProvider = ({ children }: PropsWithChildre (set) => ({ currentElectionRoundId: '', setCurrentElectionRoundId: (electionRoundId: string) => set({ currentElectionRoundId: electionRoundId }), - - isMonitoringNgoForCitizenReporting: false, - setIsMonitoringNgoForCitizenReporting: (isMonitoringNgoForCitizenReporting: boolean) => set({ isMonitoringNgoForCitizenReporting }), - - isCoalitionLeader: false, - setIsCoalitionLeader: (isCoalitionLeader: boolean) => set({ isCoalitionLeader }), }), { - name: 'current-election-round', // name of the item in the storage (must be unique) + name: 'current-election-round' } ) ); } return ( - - {children} - + {children} ); }; -export function useCurrentElectionRoundStore( - selector: (state: ExtractState) => U, -) { +export function useCurrentElectionRoundStore(selector: (state: ExtractState) => U) { const store = useContext(CurrentElectionRoundContext); - if (!store) throw "Missing StoreProvider"; + if (!store) throw 'Missing StoreProvider'; return useStoreWithEqualityFn(store, selector); } diff --git a/web/src/features/election-event/components/Dashboard/Dashboard.tsx b/web/src/features/election-event/components/Dashboard/Dashboard.tsx index 4e047fe5d..8a1c583f2 100644 --- a/web/src/features/election-event/components/Dashboard/Dashboard.tsx +++ b/web/src/features/election-event/components/Dashboard/Dashboard.tsx @@ -5,7 +5,7 @@ import FormsDashboard from '@/features/forms/components/Dashboard/Dashboard'; import LocationsDashboard from '@/features/locations/components/Dashboard/Dashboard'; import PollingStationsDashboard from '@/features/polling-stations/components/Dashboard/Dashboard'; import { cn } from '@/lib/utils'; -import { getRouteApi } from '@tanstack/react-router'; +import { getRouteApi, useNavigate } from '@tanstack/react-router'; import { ReactElement, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useElectionRoundDetails } from '../../hooks/election-event-hooks'; @@ -13,17 +13,16 @@ import GuidesDashboard from '../Guides/GuidesDashboard'; import ElectionEventDetails from '../ElectionEventDetails/ElectionEventDetails'; import { GuidePageType } from '../../models/guide'; import CitizenNotificationsDashboard from '@/features/CitizenNotifications/CitizenNotificationsDashboard/CitizenNotificationsDashboard'; +import { Route } from '@/routes/election-event/$tab'; -const routeApi = getRouteApi('/election-event/$tab'); export default function ElectionEventDashboard(): ReactElement { const { t } = useTranslation(); - const { tab } = routeApi.useParams(); + const { tab } = Route.useParams(); const [currentTab, setCurrentTab] = useState(tab); const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore((s) => s.isMonitoringNgoForCitizenReporting); - const navigate = routeApi.useNavigate(); + const navigate = useNavigate(); function handleTabChange(tab: string): void { setCurrentTab(tab); @@ -35,21 +34,20 @@ export default function ElectionEventDashboard(): ReactElement { } const { data: electionEvent } = useElectionRoundDetails(currentElectionRoundId); - return ( } backButton={<>}> {t('electionEvent.eventDetails.tabTitle')} {t('electionEvent.pollingStations.tabTitle')} - {isMonitoringNgoForCitizenReporting && ( + {electionEvent?.isMonitoringNgoForCitizenReporting && ( {t('electionEvent.locations.tabTitle')} )} {t('electionEvent.guides.observerGuidesTabTitle')} - {isMonitoringNgoForCitizenReporting && ( + {electionEvent?.isMonitoringNgoForCitizenReporting && ( <> {t('electionEvent.guides.citizenGuidesTabTitle')} @@ -68,7 +66,7 @@ export default function ElectionEventDashboard(): ReactElement { - {isMonitoringNgoForCitizenReporting && ( + {electionEvent?.isMonitoringNgoForCitizenReporting && ( @@ -78,7 +76,7 @@ export default function ElectionEventDashboard(): ReactElement { - {isMonitoringNgoForCitizenReporting && ( + {electionEvent?.isMonitoringNgoForCitizenReporting && ( <> diff --git a/web/src/features/election-event/hooks/election-event-hooks.ts b/web/src/features/election-event/hooks/election-event-hooks.ts index bfdf7c013..0dd6e1c53 100644 --- a/web/src/features/election-event/hooks/election-event-hooks.ts +++ b/web/src/features/election-event/hooks/election-event-hooks.ts @@ -11,7 +11,6 @@ export function useElectionRoundDetails(electionRoundId: string): ElectionEventR return useQuery({ queryKey: electionRoundKeys.detail(electionRoundId!), queryFn: async () => { - const response = await authApi.get(`/election-rounds/${electionRoundId}`); return { diff --git a/web/src/features/election-event/models/election-event.ts b/web/src/features/election-event/models/election-event.ts index 47d5f0691..cc6faea0c 100644 --- a/web/src/features/election-event/models/election-event.ts +++ b/web/src/features/election-event/models/election-event.ts @@ -1,4 +1,4 @@ -import { ElectionRoundStatus } from "@/common/types"; +import { ElectionRoundStatus } from '@/common/types'; export interface ElectionEvent { id: string; @@ -14,4 +14,8 @@ export interface ElectionEvent { countryFullName: string; createdOn: string; lastModifiedOn: string; + monitoringNgoId: string; + country: string; + isMonitoringNgoForCitizenReporting: boolean; + isCoalitionLeader: boolean; } diff --git a/web/src/features/filtering/components/FormTypeFilter.tsx b/web/src/features/filtering/components/FormTypeFilter.tsx index d3e8d49a6..60068d787 100644 --- a/web/src/features/filtering/components/FormTypeFilter.tsx +++ b/web/src/features/filtering/components/FormTypeFilter.tsx @@ -1,5 +1,6 @@ import { ZFormType } from '@/common/types'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { useElectionRoundDetails } from '@/features/election-event/hooks/election-event-hooks'; import { SelectFilter, SelectFilterOption } from '@/features/filtering/components/SelectFilter'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; @@ -13,7 +14,8 @@ export const FormTypeFilter: FC = () => { navigateHandler({ [FILTER_KEY.FormTypeFilter]: value }); }; - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore((s) => s.isMonitoringNgoForCitizenReporting); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); const selectOptions = useMemo(() => { const options: SelectFilterOption[] = [ @@ -39,7 +41,7 @@ export const FormTypeFilter: FC = () => { }, ]; - if (isMonitoringNgoForCitizenReporting) { + if (electionRound?.isMonitoringNgoForCitizenReporting) { options.push({ value: ZFormType.Values.IncidentReporting, label: mapFormType(ZFormType.Values.IncidentReporting), @@ -51,7 +53,7 @@ export const FormTypeFilter: FC = () => { }); return options; - }, [isMonitoringNgoForCitizenReporting]); + }, [electionRound?.isMonitoringNgoForCitizenReporting]); return ( s.currentElectionRoundId); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore(s => s.isMonitoringNgoForCitizenReporting); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); const newFormFormSchema = z.object({ code: z.string().nonempty('Form code is required'), @@ -109,7 +110,7 @@ function CreateForm() { {mapFormType(ZFormType.Values.Opening)} {mapFormType(ZFormType.Values.Voting)} {mapFormType(ZFormType.Values.ClosingAndCounting)} - {isMonitoringNgoForCitizenReporting && {mapFormType(ZFormType.Values.CitizenReporting)}} + {electionRound?.isMonitoringNgoForCitizenReporting && {mapFormType(ZFormType.Values.CitizenReporting)}} {mapFormType(ZFormType.Values.IncidentReporting)} {mapFormType(ZFormType.Values.Other)} diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx index 43b3f8b30..8c40767cb 100644 --- a/web/src/features/forms/components/Dashboard/Dashboard.tsx +++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx @@ -19,7 +19,6 @@ import { LanguageBadge } from '@/components/ui/language-badge'; import { Separator } from '@/components/ui/separator'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from '@/components/ui/use-toast'; -import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { useLanguages } from '@/hooks/languages'; import i18n from '@/i18n'; @@ -45,6 +44,8 @@ import AddTranslationsDialog, { useAddTranslationsDialog } from './AddTranslatio import CreateForm from './CreateForm'; import { FormFilters } from './FormFilters/FormFilters'; import EditFormAccessDialog, { useEditFormAccessDialog } from './EditFormAccessDialog'; +import { useElectionRoundDetails } from '@/features/election-event/hooks/election-event-hooks'; +import { useCurrentElectionRoundStore } from '@/context/election-round.store'; export default function FormsDashboard(): ReactElement { const navigate = useNavigate(); @@ -70,8 +71,9 @@ export default function FormsDashboard(): ReactElement { const confirm = useConfirm(); const { data: languages } = useLanguages(); - const { currentElectionRoundId, isMonitoringNgoForCitizenReporting, isCoalitionLeader } = - useCurrentElectionRoundStore((s) => s); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); + const columnHelper = createColumnHelper(); const formColDefs: ColumnDef[] = useMemo(() => { @@ -210,7 +212,7 @@ export default function FormsDashboard(): ReactElement { }), ]; - if (isMonitoringNgoForCitizenReporting) { + if (electionRound?.isMonitoringNgoForCitizenReporting) { defaultColumns.splice( 1, 0, @@ -245,7 +247,7 @@ export default function FormsDashboard(): ReactElement { ); } - if (isCoalitionLeader) { + if (electionRound?.isCoalitionLeader) { defaultColumns.push( columnHelper.display({ id: 'sharedWith', @@ -513,7 +515,7 @@ export default function FormsDashboard(): ReactElement { } return defaultColumns; - }, [currentElectionRoundId, isMonitoringNgoForCitizenReporting, isCoalitionLeader]); + }, [currentElectionRoundId, electionRound?.isMonitoringNgoForCitizenReporting, electionRound?.isCoalitionLeader]); const [isFiltering, setIsFiltering] = useState(filteringIsActive); diff --git a/web/src/features/forms/components/Dashboard/EditFormAccessDialog.tsx b/web/src/features/forms/components/Dashboard/EditFormAccessDialog.tsx index 789663b33..e0bcbb7d4 100644 --- a/web/src/features/forms/components/Dashboard/EditFormAccessDialog.tsx +++ b/web/src/features/forms/components/Dashboard/EditFormAccessDialog.tsx @@ -109,7 +109,6 @@ function EditFormAccessDialog() { } const handleToggleAll = (checked: boolean) => { - console.log(checked); if (checked) { setNgosSharedWith(filteredNGOs.map((ngo) => ngo.id)); } else { diff --git a/web/src/features/forms/components/EditForm/EditFormDetails.tsx b/web/src/features/forms/components/EditForm/EditFormDetails.tsx index 984840ec6..ed526c537 100644 --- a/web/src/features/forms/components/EditForm/EditFormDetails.tsx +++ b/web/src/features/forms/components/EditForm/EditFormDetails.tsx @@ -12,6 +12,7 @@ import { useFormContext, useWatch } from 'react-hook-form'; import { EditFormType } from './EditForm'; import { changeLanguageCode, mapFormType } from '@/lib/utils'; import { useEffect, useRef, useState } from 'react'; +import { useElectionRoundDetails } from '@/features/election-event/hooks/election-event-hooks'; export interface EditFormDetailsProps { languageCode: string; @@ -20,7 +21,8 @@ export interface EditFormDetailsProps { function EditFormDetails({ languageCode }: EditFormDetailsProps) { const { t } = useTranslation(); const form = useFormContext(); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore((s) => s.isMonitoringNgoForCitizenReporting); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); const formType = useWatch({ control: form.control, name: 'formType' }); const icon = useWatch({ control: form.control, name: 'icon' }); @@ -141,7 +143,7 @@ function EditFormDetails({ languageCode }: EditFormDetailsProps) { {mapFormType(ZFormType.Values.ClosingAndCounting)} - {isMonitoringNgoForCitizenReporting && ( + {electionRound?.isMonitoringNgoForCitizenReporting && ( {mapFormType(ZFormType.Values.CitizenReporting)} @@ -157,7 +159,7 @@ function EditFormDetails({ languageCode }: EditFormDetailsProps) { )} /> - {formType === ZFormType.Values.CitizenReporting && isMonitoringNgoForCitizenReporting ? ( + {formType === ZFormType.Values.CitizenReporting && electionRound?.isMonitoringNgoForCitizenReporting ? ( <> (); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore(s => s.isMonitoringNgoForCitizenReporting); - + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); return (
@@ -39,7 +40,7 @@ function EditFormTranslationDetails({ languageCode }: EditFormTranslationDetails {mapFormType(ZFormType.Values.Opening)} {mapFormType(ZFormType.Values.Voting)} {mapFormType(ZFormType.Values.ClosingAndCounting)} - {isMonitoringNgoForCitizenReporting && {mapFormType(ZFormType.Values.CitizenReporting)}} + {electionRound?.isMonitoringNgoForCitizenReporting && {mapFormType(ZFormType.Values.CitizenReporting)}} {mapFormType(ZFormType.Values.IncidentReporting)} {mapFormType(ZFormType.Values.Other)} diff --git a/web/src/features/ngo-admin-dashboard/components/Dashboard/Dashboard.tsx b/web/src/features/ngo-admin-dashboard/components/Dashboard/Dashboard.tsx index edc83c485..612bbb0f6 100644 --- a/web/src/features/ngo-admin-dashboard/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngo-admin-dashboard/components/Dashboard/Dashboard.tsx @@ -30,6 +30,7 @@ import LevelStatistics from '../LevelStatisticsCard/LevelStatisticsCard'; import useDashboardExpandedChartsStore from './dashboard-config.store'; import { DataSourceSwitcher } from '@/components/DataSourceSwitcher/DataSourceSwitcher'; import { useDataSource } from '@/common/data-source-store'; +import { useElectionRoundDetails } from '@/features/election-event/hooks/election-event-hooks'; export default function NgoAdminDashboard(): FunctionComponent { const { t } = useTranslation('translation', { keyPrefix: 'ngoAdminDashboard' }); @@ -47,11 +48,11 @@ export default function NgoAdminDashboard(): FunctionComponent { const incidentReportsChartRef = useRef(null); const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore((s) => s.isMonitoringNgoForCitizenReporting); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); const dataSource = useDataSource(); const { data: statistics } = useElectionRoundStatistics(currentElectionRoundId, dataSource); - console.log(statistics) + const getInterval = useCallback((histogram: HistogramEntry[] | undefined) => { if (histogram && histogram.some((x) => x)) { const data = histogram.map((x) => new Date(x.bucket).getTime()); @@ -339,7 +340,7 @@ export default function NgoAdminDashboard(): FunctionComponent { /> - {isMonitoringNgoForCitizenReporting && ( + {electionRound?.isMonitoringNgoForCitizenReporting && ( s.isMonitoringNgoForCitizenReporting); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { data: electionRound } = useElectionRoundDetails(currentElectionRoundId); + const navigate = useNavigate(); const search = Route.useSearch(); const { tab } = search; @@ -48,11 +51,11 @@ export default function ResponsesDashboard(): ReactElement { }}> Form answers Quick reports - {isMonitoringNgoForCitizenReporting && Citizen reports} + {electionRound?.isMonitoringNgoForCitizenReporting && Citizen reports} {/* Incident reports */} @@ -68,7 +71,7 @@ export default function ResponsesDashboard(): ReactElement { */} - {isMonitoringNgoForCitizenReporting && ( + {electionRound?.isMonitoringNgoForCitizenReporting && ( From 333e6befdec7f2d5adf4fdf334e137910a339ece Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Fri, 22 Nov 2024 21:51:50 +0200 Subject: [PATCH 2/3] Fix bug with invitations --- .../MonitoringObserverModel.cs | 6 +- .../Services/ObserverImportService.cs | 63 +++-- .../GetNgoAdminStatistics/Endpoint.cs | 1 - .../Monitoring/Endpoint.cs | 1 - .../ObserverModel.cs | 15 +- .../ApplicationUser.cs | 3 +- .../ApplicationUserAggregate/UserStatus.cs | 1 + .../MonitoringObserver.cs | 22 +- .../Endpoints/GetEndpointTests.cs | 4 - .../ApiTesting.cs | 28 ++- .../CustomWebApplicationFactory.cs | 54 ++-- .../Db/TestcontainersTestDatabase.cs | 4 +- .../FormSubmissions/GetAggregatedTests.cs | 1 - .../ImportObserversTests.cs | 238 ++++++++++++++++++ .../Features/QuickReports/ListTests.cs | 6 +- .../HttpClientExtensions.cs | 29 +++ 16 files changed, 392 insertions(+), 84 deletions(-) create mode 100644 api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs diff --git a/api/src/Feature.MonitoringObservers/MonitoringObserverModel.cs b/api/src/Feature.MonitoringObservers/MonitoringObserverModel.cs index 554c2fc41..6d7aabc74 100644 --- a/api/src/Feature.MonitoringObservers/MonitoringObserverModel.cs +++ b/api/src/Feature.MonitoringObservers/MonitoringObserverModel.cs @@ -1,6 +1,4 @@ -using System.Text.Json.Serialization; -using Ardalis.SmartEnum.SystemTextJson; -using Vote.Monitor.Domain.Entities.MonitoringObserverAggregate; +using Vote.Monitor.Domain.Entities.MonitoringObserverAggregate; namespace Feature.MonitoringObservers; @@ -14,8 +12,6 @@ public class MonitoringObserverModel public string PhoneNumber { get; init; } public string[] Tags { get; init; } public DateTime? LatestActivityAt { get; init; } - - [JsonConverter(typeof(SmartEnumNameConverter))] public MonitoringObserverStatus Status { get; init; } } diff --git a/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs b/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs index 63c939c88..293790d58 100644 --- a/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs +++ b/api/src/Feature.MonitoringObservers/Services/ObserverImportService.cs @@ -101,21 +101,12 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, existingAccount.NewInvite(); var newObserver = ObserverAggregate.Create(existingAccount); var newMonitoringObserver = MonitoringObserverAggregate.CreateForExisting(electionRoundId, - monitoringNgo.Id, newObserver.Id, observer.Tags ?? []); + monitoringNgo.Id, newObserver.Id, observer.Tags ?? [], existingAccount.Status); await context.Observers.AddAsync(newObserver, ct); await context.MonitoringObservers.AddAsync(newMonitoringObserver, ct); - var endpointUri = new Uri(Path.Combine($"{_apiConfig.WebAppUrl}", "accept-invite")); - string acceptInviteUrl = QueryHelpers.AddQueryString(endpointUri.ToString(), "invitationToken", - existingAccount.InvitationToken!); - - var invitationNewUserEmailProps = new InvitationNewUserEmailProps(FullName: fullName, - CdnUrl: _apiConfig.WebAppUrl, - AcceptUrl: acceptInviteUrl, - NgoName: ngoName, - ElectionRoundDetails: electionRoundName); - - var email = emailFactory.GenerateNewUserInvitationEmail(invitationNewUserEmailProps); + var email = GenerateCreateAccountEmail(existingAccount.InvitationToken!, fullName, ngoName, + electionRoundName); jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body); } else @@ -123,13 +114,16 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, var newMonitoringObserver = MonitoringObserverAggregate.CreateForExisting(electionRoundId, monitoringNgo.Id, existingObserver.Id, - observer.Tags ?? []); + observer.Tags ?? [], + existingObserver.ApplicationUser.Status); await context.MonitoringObservers.AddAsync(newMonitoringObserver, ct); - var invitationExistingUserEmailProps = new InvitationExistingUserEmailProps(FullName: fullName, - CdnUrl: _apiConfig.WebAppUrl, NgoName: ngoName, ElectionRoundDetails: electionRoundName); - var email = emailFactory.GenerateInvitationExistingUserEmail(invitationExistingUserEmailProps); + var email = existingObserver.ApplicationUser.Status == UserStatus.Pending + ? GenerateCreateAccountEmail(existingObserver.ApplicationUser.InvitationToken!, + existingObserver.ApplicationUser.DisplayName, ngoName, electionRoundName) + : GenerateNotificationEmail(fullName, ngoName, electionRoundName); + jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body); } } @@ -151,15 +145,8 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, newObserver.Id, observer.Tags ?? []); await context.Observers.AddAsync(newObserver, ct); await context.MonitoringObservers.AddAsync(newMonitoringObserver, ct); - var endpointUri = new Uri(Path.Combine($"{_apiConfig.WebAppUrl}", "accept-invite")); - string acceptInviteUrl = - QueryHelpers.AddQueryString(endpointUri.ToString(), "invitationToken", user.InvitationToken!); - var invitationNewUserEmailProps = new InvitationNewUserEmailProps(FullName: fullName, - CdnUrl: _apiConfig.WebAppUrl, AcceptUrl: acceptInviteUrl, NgoName: ngoName, - ElectionRoundDetails: electionRoundName); - - var email = emailFactory.GenerateNewUserInvitationEmail(invitationNewUserEmailProps); + var email = GenerateCreateAccountEmail(user.InvitationToken!, fullName, ngoName, electionRoundName); jobService.EnqueueSendEmail(observer.Email, email.Subject, email.Body); } } @@ -167,9 +154,35 @@ public async Task ImportAsync(Guid electionRoundId, Guid ngoId, await context.SaveChangesAsync(ct); } + private EmailModel GenerateNotificationEmail(string fullName, string ngoName, string electionRoundName) + { + var invitationExistingUserEmailProps = new InvitationExistingUserEmailProps(FullName: fullName, + CdnUrl: _apiConfig.WebAppUrl, NgoName: ngoName, ElectionRoundDetails: electionRoundName); + + var email = emailFactory.GenerateInvitationExistingUserEmail(invitationExistingUserEmailProps); + return email; + } + + private EmailModel GenerateCreateAccountEmail(string invitationToken, string fullName, string ngoName, + string electionRoundName) + { + var endpointUri = new Uri(Path.Combine($"{_apiConfig.WebAppUrl}", "accept-invite")); + string acceptInviteUrl = + QueryHelpers.AddQueryString(endpointUri.ToString(), "invitationToken", invitationToken); + + var invitationNewUserEmailProps = new InvitationNewUserEmailProps(FullName: fullName, + CdnUrl: _apiConfig.WebAppUrl, + AcceptUrl: acceptInviteUrl, + NgoName: ngoName, + ElectionRoundDetails: electionRoundName); + + var email = emailFactory.GenerateNewUserInvitationEmail(invitationNewUserEmailProps); + return email; + } + private static string GetFullName(MonitoringObserverImportModel observer) { return observer.FirstName + " " + observer.LastName; } -} \ No newline at end of file +} diff --git a/api/src/Feature.Statistics/GetNgoAdminStatistics/Endpoint.cs b/api/src/Feature.Statistics/GetNgoAdminStatistics/Endpoint.cs index c2e77190d..972add650 100644 --- a/api/src/Feature.Statistics/GetNgoAdminStatistics/Endpoint.cs +++ b/api/src/Feature.Statistics/GetNgoAdminStatistics/Endpoint.cs @@ -1,7 +1,6 @@ using Dapper; using Feature.Statistics.GetNgoAdminStatistics.Models; using Microsoft.Extensions.Caching.Memory; -using NPOI.POIFS.NIO; using Vote.Monitor.Domain.ConnectionFactory; namespace Feature.Statistics.GetNgoAdminStatistics; diff --git a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs index 0233600af..7a6ef48be 100644 --- a/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.ElectionRound/Monitoring/Endpoint.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; -using Vote.Monitor.Core.Services.Security; using Vote.Monitor.Domain; namespace Vote.Monitor.Api.Feature.ElectionRound.Monitoring; diff --git a/api/src/Vote.Monitor.Api.Feature.Observer/ObserverModel.cs b/api/src/Vote.Monitor.Api.Feature.Observer/ObserverModel.cs index 2ae45ae1f..1203ebb5e 100644 --- a/api/src/Vote.Monitor.Api.Feature.Observer/ObserverModel.cs +++ b/api/src/Vote.Monitor.Api.Feature.Observer/ObserverModel.cs @@ -3,14 +3,15 @@ public record ObserverModel { public Guid Id { get; init; } - public required string FirstName { get; init; } - public required string LastName { get; init; } - public required string Email { get; init; } + public string FirstName { get; init; } + public string LastName { get; init; } + public string Email { get; init; } - public required string PhoneNumber { get; init; } + public string PhoneNumber { get; init; } [JsonConverter(typeof(SmartEnumNameConverter))] - public required UserStatus Status { get; init; } - public required DateTime CreatedOn { get; init; } - public required DateTime? LastModifiedOn { get; init; } + public UserStatus Status { get; init; } + + public DateTime CreatedOn { get; init; } + public DateTime? LastModifiedOn { get; init; } } diff --git a/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/ApplicationUser.cs b/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/ApplicationUser.cs index b983d4ba8..41bf239c5 100644 --- a/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/ApplicationUser.cs +++ b/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/ApplicationUser.cs @@ -33,7 +33,7 @@ private ApplicationUser(UserRole role, string firstName, string lastName, string NormalizedUserName = email.Trim().ToUpperInvariant(); PhoneNumber = phoneNumber?.Trim(); - Status = UserStatus.Active; + Status = UserStatus.Pending; Preferences = UserPreferences.Defaults; if (string.IsNullOrEmpty(password.Trim())) @@ -80,6 +80,7 @@ public void AcceptInvite(string password) var hasher = new PasswordHasher(); PasswordHash = hasher.HashPassword(this, password); EmailConfirmed = true; + Status = UserStatus.Active; } public void NewInvite() diff --git a/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/UserStatus.cs b/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/UserStatus.cs index f0fdc7019..17dde106c 100644 --- a/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/UserStatus.cs +++ b/api/src/Vote.Monitor.Domain/Entities/ApplicationUserAggregate/UserStatus.cs @@ -4,6 +4,7 @@ public sealed class UserStatus : SmartEnum { public static readonly UserStatus Active = new(nameof(Active), nameof(Active)); + public static readonly UserStatus Pending = new(nameof(Pending), nameof(Pending)); public static readonly UserStatus Deactivated = new(nameof(Deactivated), nameof(Deactivated)); /// Gets an item associated with the specified value. Parses SmartEnum when used as query params diff --git a/api/src/Vote.Monitor.Domain/Entities/MonitoringObserverAggregate/MonitoringObserver.cs b/api/src/Vote.Monitor.Domain/Entities/MonitoringObserverAggregate/MonitoringObserver.cs index 3f00bb939..8dd872803 100644 --- a/api/src/Vote.Monitor.Domain/Entities/MonitoringObserverAggregate/MonitoringObserver.cs +++ b/api/src/Vote.Monitor.Domain/Entities/MonitoringObserverAggregate/MonitoringObserver.cs @@ -18,7 +18,8 @@ public class MonitoringObserver : AuditableBaseEntity, IAggregateRoot public string[] Tags { get; private set; } - private MonitoringObserver(Guid electionRoundId, Guid monitoringNgoId, Guid observerId, string[] tags, MonitoringObserverStatus status) + private MonitoringObserver(Guid electionRoundId, Guid monitoringNgoId, Guid observerId, string[] tags, + MonitoringObserverStatus status) { Id = Guid.NewGuid(); ElectionRoundId = electionRoundId; @@ -40,11 +41,23 @@ public void Suspend() public static MonitoringObserver Create(Guid electionRoundId, Guid monitoringNgoId, Guid observerId, string[] tags) { - return new MonitoringObserver(electionRoundId, monitoringNgoId, observerId, tags, MonitoringObserverStatus.Pending); + return new MonitoringObserver(electionRoundId, monitoringNgoId, observerId, tags, + MonitoringObserverStatus.Pending); } - public static MonitoringObserver CreateForExisting(Guid electionRoundId, Guid monitoringNgoId, Guid observerId, string[] tags) + + public static MonitoringObserver CreateForExisting(Guid electionRoundId, + Guid monitoringNgoId, + Guid observerId, + string[] tags, + UserStatus accountStatus) { - return new MonitoringObserver(electionRoundId, monitoringNgoId, observerId, tags, MonitoringObserverStatus.Active); + MonitoringObserverStatus status = accountStatus == UserStatus.Active + ? MonitoringObserverStatus.Active + : accountStatus == UserStatus.Pending + ? MonitoringObserverStatus.Pending + : MonitoringObserverStatus.Suspended; + + return new MonitoringObserver(electionRoundId, monitoringNgoId, observerId, tags, status); } public void Update(MonitoringObserverStatus status, string[] tags) @@ -60,7 +73,6 @@ public void Update(MonitoringObserverStatus status, string[] tags) #pragma warning disable CS8618 // Required by Entity Framework private MonitoringObserver() { - } #pragma warning restore CS8618 } diff --git a/api/tests/Feature.QuickReports.UnitTests/Endpoints/GetEndpointTests.cs b/api/tests/Feature.QuickReports.UnitTests/Endpoints/GetEndpointTests.cs index 06c573178..e33f2ae12 100644 --- a/api/tests/Feature.QuickReports.UnitTests/Endpoints/GetEndpointTests.cs +++ b/api/tests/Feature.QuickReports.UnitTests/Endpoints/GetEndpointTests.cs @@ -1,13 +1,9 @@ using System.Security.Claims; using Feature.QuickReports.Get; -using Feature.QuickReports.Specifications; using Microsoft.AspNetCore.Authorization; -using NSubstitute.ReturnsExtensions; using Vote.Monitor.Core.Services.FileStorage.Contracts; using Vote.Monitor.Core.Services.Security; using Vote.Monitor.Domain.ConnectionFactory; -using Vote.Monitor.Domain.Entities.QuickReportAggregate; -using Vote.Monitor.Domain.Entities.QuickReportAttachmentAggregate; namespace Feature.QuickReports.UnitTests.Endpoints; diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs index 02c92d89b..b2558488a 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs @@ -1,5 +1,8 @@ -using NSubstitute; +using Job.Contracts; +using NSubstitute; +using NSubstitute.ClearExtensions; using Vote.Monitor.Api.IntegrationTests.Db; +using Vote.Monitor.Core.Services.EmailTemplating; using Vote.Monitor.Core.Services.Time; namespace Vote.Monitor.Api.IntegrationTests; @@ -10,19 +13,30 @@ public class ApiTesting private static ITestDatabase _database = null!; private static CustomWebApplicationFactory _factory = null!; private static ITimeProvider _apiTimeProvider = null!; + public static ITimeProvider ApiTimeProvider => _apiTimeProvider; + + private static IEmailTemplateFactory _emailFactory = null!; + public static IEmailTemplateFactory EmailFactory => _emailFactory; + private static IJobService _jobService = null!; + public static IJobService JobService => _jobService; [OneTimeSetUp] public async Task RunBeforeAnyTests() { _database = await TestDatabaseFactory.CreateAsync(); + _apiTimeProvider = Substitute.For(); _apiTimeProvider.UtcNow.Returns(_ => DateTime.UtcNow); _apiTimeProvider.UtcNowDate.Returns(_ => DateOnly.FromDateTime(DateTime.UtcNow)); - + + _emailFactory = Substitute.For(); + _jobService = Substitute.For(); + await _database.InitialiseAsync(); - _factory = new CustomWebApplicationFactory(_database.GetConnectionString(), _database.GetConnection(), _apiTimeProvider); + _factory = new CustomWebApplicationFactory(_database.GetConnectionString(), _database.GetConnection(), + _apiTimeProvider, _emailFactory, _jobService); } - + public static string DbConnectionString => _database.GetConnectionString(); public static async Task ResetState() @@ -30,15 +44,15 @@ public static async Task ResetState() try { await _database.ResetAsync(); + _emailFactory.ClearSubstitute(); + _jobService.ClearSubstitute(); } catch (Exception e) { TestContext.Out.WriteLine(e.Message); } } - - public static ITimeProvider ApiTimeProvider => _apiTimeProvider; - + public static HttpClient CreateClient() { return _factory.CreateClient(); diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs index 9ea45dc1f..e5be52dbd 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,4 +1,5 @@ using System.Data.Common; +using Job.Contracts; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -7,6 +8,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; using Serilog; +using Vote.Monitor.Core.Services.EmailTemplating; using Vote.Monitor.Core.Services.Security; using Vote.Monitor.Core.Services.Time; using Vote.Monitor.Domain; @@ -18,53 +20,65 @@ public class CustomWebApplicationFactory : WebApplicationFactory private readonly DbConnection _connection; private readonly ITimeProvider _timeProvider; private readonly NpgsqlConnectionStringBuilder _connectionDetails; + private readonly IEmailTemplateFactory _emailFactory; + private readonly IJobService _jobService; public const string AdminEmail = "integration@testing.com"; public const string AdminPassword = "toTallyNotTestPassw0rd"; - public CustomWebApplicationFactory(string connectionString, DbConnection connection, ITimeProvider timeProvider) + public CustomWebApplicationFactory(string connectionString, DbConnection connection, ITimeProvider timeProvider, + IEmailTemplateFactory emailFactory, IJobService jobService) { _connection = connection; - _connectionDetails = new NpgsqlConnectionStringBuilder { ConnectionString = connectionString }; + _connectionDetails = new NpgsqlConnectionStringBuilder { ConnectionString = connectionString }; _timeProvider = timeProvider; + _emailFactory = emailFactory; + _jobService = jobService; } protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.UseSetting("AuthFeatureConfig:JWTConfig:TokenSigningKey", "SecretKeyOfDoomThatMustBeAMinimumNumberOfBytes" ); - builder.UseSetting("AuthFeatureConfig:JWTConfig:TokenExpirationInMinutes", "10080" ); + builder.UseSetting("AuthFeatureConfig:JWTConfig:TokenSigningKey", + "SecretKeyOfDoomThatMustBeAMinimumNumberOfBytes"); + builder.UseSetting("AuthFeatureConfig:JWTConfig:TokenExpirationInMinutes", "10080"); builder.UseSetting("AuthFeatureConfig:JWTConfig:RefreshTokenExpirationInDays", "30"); - builder.UseSetting("Domain:DbConnectionConfig:Server", _connectionDetails.Host ); - builder.UseSetting("Domain:DbConnectionConfig:Port", _connectionDetails.Port.ToString() ); - builder.UseSetting("Domain:DbConnectionConfig:Database", _connectionDetails.Database ); - builder.UseSetting("Domain:DbConnectionConfig:UserId", _connectionDetails.Username ); + builder.UseSetting("Domain:DbConnectionConfig:Server", _connectionDetails.Host); + builder.UseSetting("Domain:DbConnectionConfig:Port", _connectionDetails.Port.ToString()); + builder.UseSetting("Domain:DbConnectionConfig:Database", _connectionDetails.Database); + builder.UseSetting("Domain:DbConnectionConfig:UserId", _connectionDetails.Username); builder.UseSetting("Domain:DbConnectionConfig:Password", _connectionDetails.Password); - builder.UseSetting("Core:EnableHangfire", "false" ); - builder.UseSetting("Core:HangfireConnectionConfig:Server", _connectionDetails.Host ); - builder.UseSetting("Core:HangfireConnectionConfig:Port", _connectionDetails.Port.ToString() ); + builder.UseSetting("Core:EnableHangfire", "false"); + builder.UseSetting("Core:HangfireConnectionConfig:Server", _connectionDetails.Host); + builder.UseSetting("Core:HangfireConnectionConfig:Port", _connectionDetails.Port.ToString()); builder.UseSetting("Core:HangfireConnectionConfig:Database", _connectionDetails.Database); builder.UseSetting("Core:HangfireConnectionConfig:UserId", _connectionDetails.Username); builder.UseSetting("Core:HangfireConnectionConfig:Password", _connectionDetails.Password); - builder.UseSetting("Sentry:Enabled", "false" ); - builder.UseSetting("Sentry:Dsn", "" ); - builder.UseSetting("Sentry:TracesSampleRate", "0.2" ); - builder.UseSetting("Seeders:PlatformAdminSeeder:FirstName", "John" ); - builder.UseSetting("Seeders:PlatformAdminSeeder:LastName", "Doe" ); + builder.UseSetting("Sentry:Enabled", "false"); + builder.UseSetting("Sentry:Dsn", ""); + builder.UseSetting("Sentry:TracesSampleRate", "0.2"); + builder.UseSetting("Seeders:PlatformAdminSeeder:FirstName", "John"); + builder.UseSetting("Seeders:PlatformAdminSeeder:LastName", "Doe"); builder.UseSetting("Seeders:PlatformAdminSeeder:Email", AdminEmail); - builder.UseSetting("Seeders:PlatformAdminSeeder:PhoneNumber", "1234567890" ); + builder.UseSetting("Seeders:PlatformAdminSeeder:PhoneNumber", "1234567890"); builder.UseSetting("Seeders:PlatformAdminSeeder:Password", AdminPassword); - + builder.ConfigureTestServices((services) => { services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(_ => _timeProvider); - + services.AddSingleton(_emailFactory); + services.AddSingleton(_ => _jobService); + services .RemoveAll>() .AddDbContext((sp, options) => { options.UseNpgsql(_connection) - .AddInterceptors(new AuditingInterceptor(sp.GetRequiredService(), _timeProvider)); + .AddInterceptors(new AuditingInterceptor(sp.GetRequiredService(), + _timeProvider)); }); services.AddLogging(logging => diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/Db/TestcontainersTestDatabase.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/Db/TestcontainersTestDatabase.cs index f520c7251..a2d3878d9 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/Db/TestcontainersTestDatabase.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/Db/TestcontainersTestDatabase.cs @@ -18,8 +18,8 @@ public TestcontainersTestDatabase() { _container = new PostgreSqlBuilder() .WithAutoRemove(true) - .WithExposedPort(33747) - .WithPortBinding(33747, 5432) + // .WithExposedPort(33747) + // .WithPortBinding(33747, 5432) .Build(); } diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/FormSubmissions/GetAggregatedTests.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/FormSubmissions/GetAggregatedTests.cs index ba7aeef80..3cffd6eca 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/FormSubmissions/GetAggregatedTests.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/FormSubmissions/GetAggregatedTests.cs @@ -3,7 +3,6 @@ using Vote.Monitor.Api.IntegrationTests.Scenarios; using Vote.Monitor.Api.IntegrationTests.TestCases; using Vote.Monitor.Core.Models; -using Vote.Monitor.Domain.Entities.FormAggregate; namespace Vote.Monitor.Api.IntegrationTests.Features.FormSubmissions; diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs new file mode 100644 index 000000000..83f3aeccf --- /dev/null +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/MonitoringObservers/ImportObserversTests.cs @@ -0,0 +1,238 @@ +using System.Web; +using Feature.MonitoringObservers; +using NSubstitute; +using NSubstitute.Extensions; +using Vote.Monitor.Api.Feature.Observer; +using Vote.Monitor.Api.IntegrationTests.Consts; +using Vote.Monitor.Api.IntegrationTests.Scenarios; +using Vote.Monitor.Core.Models; +using Vote.Monitor.Core.Services.EmailTemplating; +using Vote.Monitor.Core.Services.EmailTemplating.Props; +using Vote.Monitor.Domain.Entities.ApplicationUserAggregate; +using Vote.Monitor.Domain.Entities.MonitoringObserverAggregate; + +namespace Vote.Monitor.Api.IntegrationTests.Features.MonitoringObservers; + +using static ApiTesting; + +public class ImportObserversTests : BaseApiTestFixture +{ + [Test] + public void ReImportedObserversShouldBeInPendingUntilTheyAcceptInvite() + { + // Arrange + EmailFactory.GenerateNewUserInvitationEmail(Arg.Any()) + .Returns(new EmailModel(string.Empty, string.Empty)); + EmailFactory.GenerateInvitationExistingUserEmail(Arg.Any()) + .Returns(new EmailModel(string.Empty, string.Empty)); + + var scenarioData = ScenarioBuilder.New(CreateClient) + .WithNgo(ScenarioNgos.Alfa) + .WithElectionRound(ScenarioElectionRound.A, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .WithElectionRound(ScenarioElectionRound.B, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .Please(); + + var electionRoundAId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.A); + var electionRoundBId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.B); + var admin = scenarioData.AdminOfNgo(ScenarioNgo.Alfa); + var platformAdmin = scenarioData.PlatformAdmin; + + string importFile = + $""" + "Email","FirstName","LastName","PhoneNumber" + "{Guid.NewGuid()}@example.com","Alice","Smith","5551111" + """; + // Act + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", + importFile); + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundBId}/monitoring-observers:import", + importFile); + + // Assert + var observersElectionA = + admin.GetResponse>( + $"/api/election-rounds/{electionRoundAId}/monitoring-observers"); + var observersElectionB = + admin.GetResponse>( + $"/api/election-rounds/{electionRoundBId}/monitoring-observers"); + + observersElectionA.Items.Should().ContainSingle(); + observersElectionB.Items.Should().ContainSingle(); + + observersElectionA.Items.First().Status.Should().Be(MonitoringObserverStatus.Pending); + observersElectionB.Items.First().Status.Should().Be(MonitoringObserverStatus.Pending); + + var observers = platformAdmin.GetResponse>("/api/observers"); + observers.Items.Should().ContainSingle(); + observers.Items.First().Status.Should().Be(UserStatus.Pending); + } + + [Test] + public void ReImportedObserversShouldBeActiveIfTheyAcceptInvite() + { + // Arrange + EmailFactory.GenerateNewUserInvitationEmail(Arg.Any()) + .Returns(new EmailModel(string.Empty, string.Empty)); + EmailFactory.GenerateInvitationExistingUserEmail(Arg.Any()) + .Returns(new EmailModel(string.Empty, string.Empty)); + + var scenarioData = ScenarioBuilder.New(CreateClient) + .WithNgo(ScenarioNgos.Alfa) + .WithElectionRound(ScenarioElectionRound.A, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .WithElectionRound(ScenarioElectionRound.B, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .Please(); + + var electionRoundAId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.A); + var electionRoundBId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.B); + var admin = scenarioData.AdminOfNgo(ScenarioNgo.Alfa); + var platformAdmin = scenarioData.PlatformAdmin; + var invitationToken = string.Empty; + + string importFile = + $""" + "Email","FirstName","LastName","PhoneNumber" + "{Guid.NewGuid()}@example.com","Alice","Smith","5551111" + """; + + EmailFactory + .GenerateNewUserInvitationEmail(Arg.Do(x => + { + invitationToken = GetInvitationTokenFromInviteUrl(x.AcceptUrl); + })) + .Returns(new EmailModel(string.Empty, string.Empty)); + + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", + importFile); + + CreateClient().PostWithoutResponse("/api/auth/accept-invite", + new { InvitationToken = invitationToken, Password = "parola123", ConfirmPassword = "parola123" }); + + // Act + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundBId}/monitoring-observers:import", + importFile); + + // Assert + var observersElectionA = + admin.GetResponse>( + $"/api/election-rounds/{electionRoundAId}/monitoring-observers"); + var observersElectionB = + admin.GetResponse>( + $"/api/election-rounds/{electionRoundBId}/monitoring-observers"); + + observersElectionA.Items.Should().ContainSingle(); + observersElectionB.Items.Should().ContainSingle(); + + observersElectionA.Items.First().Status.Should().Be(MonitoringObserverStatus.Active); + observersElectionB.Items.First().Status.Should().Be(MonitoringObserverStatus.Active); + + var observers = platformAdmin.GetResponse>("/api/observers"); + observers.Items.Should().ContainSingle(); + observers.Items.First().Status.Should().Be(UserStatus.Active); + } + + [Test] + public void ReImportedObserversThatHavePendingAccountShouldReceiveAcceptInviteEmail() + { + // Arrange + var scenarioData = ScenarioBuilder.New(CreateClient) + .WithNgo(ScenarioNgos.Alfa) + .WithElectionRound(ScenarioElectionRound.A, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .WithElectionRound(ScenarioElectionRound.B, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .Please(); + + var aliceEmail = $"{Guid.NewGuid()}@example.com"; + string importFile = + $""" + "Email","FirstName","LastName","PhoneNumber" + "{aliceEmail}","Alice","Smith","5551111" + """; + + var electionRoundAId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.A); + var electionRoundBId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.B); + var admin = scenarioData.AdminOfNgo(ScenarioNgo.Alfa); + var acceptUrl = Guid.NewGuid().ToString(); + + EmailFactory + .GenerateNewUserInvitationEmail(Arg.Any()) + .Returns(new EmailModel("This is a confirm account email", acceptUrl)); + + // Act + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", + importFile); + + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundBId}/monitoring-observers:import", + importFile); + + // Assert + JobService + .Received(2) + .EnqueueSendEmail(aliceEmail, "This is a confirm account email", acceptUrl); + } + + [Test] + public void ReImportedObserversThatHaveActiveAccountShouldReceiveNotificationEmail() + { + // Arrange + var scenarioData = ScenarioBuilder.New(CreateClient) + .WithNgo(ScenarioNgos.Alfa) + .WithElectionRound(ScenarioElectionRound.A, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .WithElectionRound(ScenarioElectionRound.B, er => er.WithMonitoringNgo(ScenarioNgo.Alfa)) + .Please(); + + var electionRoundAId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.A); + var electionRoundBId = scenarioData.ElectionRoundIdByName(ScenarioElectionRound.B); + var admin = scenarioData.AdminOfNgo(ScenarioNgo.Alfa); + var invitationToken = string.Empty; + + var aliceEmail = $"{Guid.NewGuid()}@example.com"; + string importFile = + $""" + "Email","FirstName","LastName","PhoneNumber" + "{aliceEmail}","Alice","Smith","5551111" + """; + + EmailFactory + .Configure() + .GenerateNewUserInvitationEmail(Arg.Do(x => + { + invitationToken = GetInvitationTokenFromInviteUrl(x.AcceptUrl); + })) + .Returns(x => new EmailModel("This is a confirm account email", + GetInvitationTokenFromInviteUrl(x.Arg().AcceptUrl))); + + EmailFactory + .GenerateInvitationExistingUserEmail(Arg.Any()) + .Returns(new EmailModel("This is a notification email", "Notification email body")); + + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundAId}/monitoring-observers:import", + importFile); + + CreateClient().PostWithoutResponse("/api/auth/accept-invite", + new { InvitationToken = invitationToken, Password = "parola123", ConfirmPassword = "parola123" }); + + // Act + admin.PostFileWithoutResponse($"/api/election-rounds/{electionRoundBId}/monitoring-observers:import", + importFile); + + // Assert + JobService + .Received(1) + .EnqueueSendEmail(aliceEmail, + "This is a confirm account email", Arg.Is(invitationToken)); + + JobService + .Received(1) + .EnqueueSendEmail(aliceEmail, "This is a notification email", "Notification email body"); + } + + private string GetInvitationTokenFromInviteUrl(string inviteUrl) + { + // Parse the string as a Uri + Uri uri = new Uri(inviteUrl); + + // Extract query parameters + var queryParams = HttpUtility.ParseQueryString(uri.Query); + + return queryParams["invitationToken"]!; + } +} diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/QuickReports/ListTests.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/QuickReports/ListTests.cs index 78458b3c6..c2095446d 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/Features/QuickReports/ListTests.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/Features/QuickReports/ListTests.cs @@ -1,9 +1,5 @@ -using Feature.Form.Submissions.ListEntries; -using Feature.QuickReports.Get; -using Feature.QuickReports.List; +using Feature.QuickReports.List; using Vote.Monitor.Api.IntegrationTests.Consts; -using Vote.Monitor.Api.IntegrationTests.Fakers; -using Vote.Monitor.Api.IntegrationTests.Models; using Vote.Monitor.Api.IntegrationTests.Scenarios; using Vote.Monitor.Api.IntegrationTests.TestCases; using Vote.Monitor.Core.Models; diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/HttpClientExtensions.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/HttpClientExtensions.cs index 04636c2e9..7317fe9b4 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/HttpClientExtensions.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/HttpClientExtensions.cs @@ -1,5 +1,7 @@ using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; using System.Net.Http.Json; +using System.Text; using System.Text.Json; using Vote.Monitor.Api.Feature.Auth.Services; @@ -52,6 +54,33 @@ public static void PostWithoutResponse( response.EnsureSuccessStatusCode(); } + public static void PostFileWithoutResponse( + this HttpClient client, + [StringSyntax("Uri")] string requestUri, + string file, + string? fieldName="File", + string? fileName="data.csv") + { + // Create the request content + using var formData = new MultipartFormDataContent(); + + // Add the file content + using var fileStream = new MemoryStream(Encoding.UTF8.GetBytes(file)); + + var fileContent = new StreamContent(fileStream); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + formData.Add(fileContent, fieldName, fileName); + + var response = client.PostAsync(requestUri, formData).GetAwaiter().GetResult(); + + if (!response.IsSuccessStatusCode) + { + TestContext.Out.WriteLine(response.Content.ReadAsStringAsync().GetAwaiter().GetResult()); + } + + response.EnsureSuccessStatusCode(); + } + public static void PutWithoutResponse( this HttpClient client, [StringSyntax("Uri")] string? requestUri, From 13fed92934b5bae81eb75b2bfc8ce9847e5601a8 Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Fri, 22 Nov 2024 22:58:37 +0200 Subject: [PATCH 3/3] Fix stuff --- api/src/Feature.Coalitions/GetMy/Endpoint.cs | 12 +++++----- .../Models/CoalitionModel.cs | 2 ++ .../ApiTesting.cs | 24 +++++++++++++++---- .../CustomWebApplicationFactory.cs | 4 ++-- web/src/common/types.ts | 1 + web/src/components/layout/Header/Header.tsx | 1 - .../ElectionEventDetails.tsx | 2 +- 7 files changed, 32 insertions(+), 14 deletions(-) diff --git a/api/src/Feature.Coalitions/GetMy/Endpoint.cs b/api/src/Feature.Coalitions/GetMy/Endpoint.cs index 3a1fd1646..e84398b42 100644 --- a/api/src/Feature.Coalitions/GetMy/Endpoint.cs +++ b/api/src/Feature.Coalitions/GetMy/Endpoint.cs @@ -29,11 +29,11 @@ public override async Task, NotFound>> ExecuteAsync(R } var coalition = await context.Coalitions - .Include(x=>x.Memberships) - .ThenInclude(x=>x.MonitoringNgo) - .ThenInclude(x=>x.Ngo) - .Include(x=>x.Leader) - .ThenInclude(x=>x.Ngo) + .Include(x => x.Memberships) + .ThenInclude(x => x.MonitoringNgo) + .ThenInclude(x => x.Ngo) + .Include(x => x.Leader) + .ThenInclude(x => x.Ngo) .Where(x => x.Memberships.Any(m => m.ElectionRoundId == req.ElectionRoundId && m.MonitoringNgo.NgoId == req.NgoId && m.MonitoringNgo.ElectionRoundId == req.ElectionRoundId)) @@ -42,7 +42,7 @@ public override async Task, NotFound>> ExecuteAsync(R if (coalition is null) { - return TypedResults.NotFound(); + return TypedResults.Ok(new CoalitionModel { IsInCoalition = false }); } return TypedResults.Ok(coalition); diff --git a/api/src/Feature.Coalitions/Models/CoalitionModel.cs b/api/src/Feature.Coalitions/Models/CoalitionModel.cs index f7077a46f..6353e1bd2 100644 --- a/api/src/Feature.Coalitions/Models/CoalitionModel.cs +++ b/api/src/Feature.Coalitions/Models/CoalitionModel.cs @@ -5,6 +5,7 @@ namespace Feature.NgoCoalitions.Models; public class CoalitionModel { public Guid Id { get; init; } + public bool IsInCoalition { get; set; } public string Name { get; init; } = string.Empty; public Guid LeaderId { get; set; } public string LeaderName { get; set; } = string.Empty; @@ -22,6 +23,7 @@ public class CoalitionModel Name = coalition.Name, LeaderId = coalition.Leader.NgoId, LeaderName = coalition.Leader.Ngo.Name, + IsInCoalition = true, Members = coalition.Memberships.Select(x => CoalitionMember.FromEntity(x.MonitoringNgo)).ToArray() }; } diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs index b2558488a..188ff5bfc 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/ApiTesting.cs @@ -3,6 +3,7 @@ using NSubstitute.ClearExtensions; using Vote.Monitor.Api.IntegrationTests.Db; using Vote.Monitor.Core.Services.EmailTemplating; +using Vote.Monitor.Core.Services.EmailTemplating.Props; using Vote.Monitor.Core.Services.Time; namespace Vote.Monitor.Api.IntegrationTests; @@ -14,7 +15,7 @@ public class ApiTesting private static CustomWebApplicationFactory _factory = null!; private static ITimeProvider _apiTimeProvider = null!; public static ITimeProvider ApiTimeProvider => _apiTimeProvider; - + private static IEmailTemplateFactory _emailFactory = null!; public static IEmailTemplateFactory EmailFactory => _emailFactory; private static IJobService _jobService = null!; @@ -30,6 +31,21 @@ public async Task RunBeforeAnyTests() _apiTimeProvider.UtcNowDate.Returns(_ => DateOnly.FromDateTime(DateTime.UtcNow)); _emailFactory = Substitute.For(); + _emailFactory.GenerateConfirmAccountEmail(Arg.Any()) + .Returns(new EmailModel("fake", "fake")); + + _emailFactory.GenerateResetPasswordEmail(Arg.Any()) + .Returns(new EmailModel("fake", "fake")); + + _emailFactory.GenerateInvitationExistingUserEmail(Arg.Any()) + .Returns(new EmailModel("fake", "fake")); + + _emailFactory.GenerateNewUserInvitationEmail(Arg.Any()) + .Returns(new EmailModel("fake", "fake")); + + _emailFactory.GenerateCitizenReportEmail(Arg.Any()) + .Returns(new EmailModel("fake", "fake")); + _jobService = Substitute.For(); await _database.InitialiseAsync(); @@ -44,15 +60,15 @@ public static async Task ResetState() try { await _database.ResetAsync(); - _emailFactory.ClearSubstitute(); - _jobService.ClearSubstitute(); + _emailFactory.ClearReceivedCalls(); + _jobService.ClearReceivedCalls(); } catch (Exception e) { TestContext.Out.WriteLine(e.Message); } } - + public static HttpClient CreateClient() { return _factory.CreateClient(); diff --git a/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs b/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs index e5be52dbd..9d1cc8a2e 100644 --- a/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs +++ b/api/tests/Vote.Monitor.Api.IntegrationTests/CustomWebApplicationFactory.cs @@ -69,8 +69,8 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) services.RemoveAll(); services.AddSingleton(_ => _timeProvider); - services.AddSingleton(_emailFactory); - services.AddSingleton(_ => _jobService); + services.AddTransient(_ => _emailFactory); + services.AddTransient(_ => _jobService); services .RemoveAll>() diff --git a/web/src/common/types.ts b/web/src/common/types.ts index e62c90ca6..c9cc25dfd 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -270,6 +270,7 @@ export interface CoalitionMember { } export interface Coalition { id: string; + isInCoalition: boolean; name: string; leaderId: string; leaderName: string; diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index 695c6bba3..e737c3ee7 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -93,7 +93,6 @@ const Header = (): FunctionComponent => { useEffect(() => { if (!!electionRounds) { - debugger; const electionRound = electionRounds.find((x) => x.id === currentElectionRoundId); handleSelectElectionRound(electionRound ?? electionRounds[0]); } diff --git a/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx b/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx index 13a438b46..5118d2016 100644 --- a/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx +++ b/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx @@ -56,7 +56,7 @@ export default function ElectionEventDetails() {
- {coalitionDetails && ( + {coalitionDetails?.isInCoalition && (