Skip to content

Commit

Permalink
✨ Admin | View New Users (#689)
Browse files Browse the repository at this point in the history
* Create NewUsersQuery

* Split new Query into appropriate folders. Add to Controller.

* Fix PrizeDrawService

* Add /newusers to the nav

* Calling New Users from Page

* Update Query

* Implement NewUsers.razor

* Fix issue with merge

* Revert bad changes

* Add Loading bar

* Remove commented config

* Authorize Endpoint

* Register IUserAdminService in ApiClient
  • Loading branch information
tkapa authored Feb 16, 2024
1 parent d3e6160 commit 124e72a
Show file tree
Hide file tree
Showing 11 changed files with 307 additions and 20 deletions.
131 changes: 131 additions & 0 deletions src/AdminUI/Pages/NewUsers.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
@page "/NewUsers"
@using SSW.Rewards.ApiClient.Services
@using SSW.Rewards.Enums
@using SSW.Rewards.Shared.DTOs.Users
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Newtonsoft.Json
@using Icons = MudBlazor.Icons
@using System.Text
@* @using Microsoft.AspNetCore.Authorization *@
@* @attribute [Authorize] *@
@inject IUserAdminService UserAdminService
@inject IJSRuntime JSRuntime

<MudText Typo="Typo.h2">New Users</MudText>
<MudText Typo="Typo.body1">View new users</MudText>
<MudTable Items="@_vm.NewUsers" Loading="@_loading" LoadingProgressColor="Color.Primary">
<ToolBarContent>
<MudText Typo="Typo.h6">New Users</MudText>
<MudSpacer/>
<MudStack Row="true" Spacing="5" Style="width: 50%">
<MudSelect Style="width: 50%" @bind-Value="_timeFrame" T="@LeaderboardFilter" Label="Since">
@foreach (var icon in Enum.GetValues<LeaderboardFilter>())
{
<MudSelectItem Value="@icon">@icon.ToString()</MudSelectItem>
}
</MudSelect>
<MudSwitch
@bind-Value="@_hideStaff"
Color="Color.Primary"
Style="height: fit-content; align-self: center;">
Hide Staff
</MudSwitch>
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="@FetchData"
StartIcon="@Icons.Material.Filled.Search"
Style="height: fit-content; align-self: center;">
Search
</MudButton>
<MudButton
Variant="Variant.Filled"
Color="Color.Primary"
OnClick="CopyAllUsers"
StartIcon="@Icons.Material.Filled.FileCopy"
Style="height: fit-content; align-self: center;">
Copy All
</MudButton>
</MudStack>
</ToolBarContent>
<ColGroup>
<col style="width: 50px"/>
<col />
<col />
</ColGroup>
<HeaderContent>
<MudTh Style="max-width: fit-content">Id</MudTh>
<MudTh>Name</MudTh>
<MudTh>Email</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Id">@context.UserId</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd Class="d-flex align-items-center" DataLabel="Email">
<MudStack Row="true" Spacing="5" Style="align-items: center; width: 100%">
<MudText>@context.Email</MudText>
<MudSpacer />
<MudIconButton
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.FileCopy"
OnClick="() => { CopyToClipboard(context.Email); }"
Icon="@Icons.Material.Filled.FileCopy" />
</MudStack>
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="new int[] { 10, 25, 50, 100, int.MaxValue }" InfoFormat="@($"Center {_infoFormat}")" HorizontalAlignment="HorizontalAlignment.Center" />
</PagerContent>
</MudTable>

@code {

NewUsersViewModel _vm = new ();

private string _infoFormat = "{first_item}-{last_item} of {all_items}";
private LeaderboardFilter _timeFrame = LeaderboardFilter.ThisMonth;
private bool _hideStaff = true;
private bool _loading = true;

protected override async Task OnInitializedAsync()
{
try
{
await FetchData();
}
catch (AccessTokenNotAvailableException exception)
{
exception.Redirect();
}
}

private async Task FetchData()
{
_loading = true;
_vm = await UserAdminService.GetNewUsers(_timeFrame, _hideStaff);
_loading = false;
}

private void CopyToClipboard(string text)
{
JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", text);
}

private void CopyAllUsers()
{
var serializer = JsonSerializer.Create(new JsonSerializerSettings
{
Formatting = Formatting.Indented,
});
var stringBuilder = new StringBuilder();
using (var writer = new JsonTextWriter(new StringWriter(stringBuilder)))
{
serializer.Serialize(writer, _vm.NewUsers);
}

var jsonText = stringBuilder.ToString();

CopyToClipboard(jsonText);
}
}
1 change: 1 addition & 0 deletions src/AdminUI/Shared/NavMenu.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@
<MudNavLink Href="skills" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WorkspacePremium">Skills</MudNavLink>
<MudNavLink Href="quizzes" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Quiz">Quizzes</MudNavLink>
<MudNavLink Href="prizedraw" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Celebration">Prize Draw</MudNavLink>
<MudNavLink Href="newusers" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.SupervisedUserCircle">New Users</MudNavLink>
</AuthorizeView>
</MudNavMenu>
1 change: 1 addition & 0 deletions src/ApiClient/ConfigureServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static IServiceCollection AddApiClientServices<THandler>(this IServiceCol
services.AddSingleton<IRewardAdminService, RewardAdminService>();
services.AddSingleton<ISkillsAdminService, SkillsAdminService>();
services.AddSingleton<IStaffAdminService, StaffAdminService>();
services.AddSingleton<IUserAdminService, UserAdminService>();
}

services.AddSingleton<IAchievementService, AchievementService>();
Expand Down
2 changes: 1 addition & 1 deletion src/ApiClient/Services/IPrizeDrawService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public async Task<EligibleUsersViewModel> GetEligibleUsers(GetEligibleUsersFilte
{
var result = await _httpClient.GetAsync($"{_baseRoute}GetEligibleUsers?filter={filter.Filter}&achievementId={filter.AchievementId}&filterStaff={filter.FilterStaff}", cancellationToken);

if (result.IsSuccessStatusCode)
if (result.IsSuccessStatusCode)
{
var response = await result.Content.ReadFromJsonAsync<EligibleUsersViewModel>(cancellationToken: cancellationToken);

Expand Down
40 changes: 40 additions & 0 deletions src/ApiClient/Services/IUserAdminService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Net.Http.Json;
using SSW.Rewards.Shared.DTOs.Users;

namespace SSW.Rewards.ApiClient.Services;

public interface IUserAdminService
{
Task<NewUsersViewModel> GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default);
}

public class UserAdminService : IUserAdminService
{
private readonly HttpClient _httpClient;

private const string _baseRoute = "api/User/";

public UserAdminService(IHttpClientFactory httpClientFactory)
{
_httpClient = httpClientFactory.CreateClient(Constants.AuthenticatedClient);
}

public async Task<NewUsersViewModel> GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default)
{
var result = await _httpClient.GetAsync($"{_baseRoute}GetNewUsers?filter={filter}&filterStaff={filterStaff}", cancellationToken);

if (result.IsSuccessStatusCode)
{
var response = await result.Content.ReadFromJsonAsync<NewUsersViewModel>(cancellationToken: cancellationToken);

if (response is not null)
{
return response;
}
}

var responseContent = await result.Content.ReadAsStringAsync(cancellationToken);

throw new Exception($"Failed to get new users: {responseContent}");
}
}
36 changes: 18 additions & 18 deletions src/ApiClient/Services/IUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,23 +161,23 @@ public async Task<UserRewardsViewModel> GetUserRewards(int userId, CancellationT

throw new Exception($"Failed to get user rewards: {responseContent}");
}

public async Task<NewUsersViewModel> GetNewUsers(LeaderboardFilter filter, bool filterStaff, CancellationToken cancellationToken = default)
{
var result = await _httpClient.GetAsync($"{_baseRoute}GetNewUsers?filter={filter}&filterStaff={filterStaff}", cancellationToken);

if (result.IsSuccessStatusCode)
{
var response = await result.Content.ReadFromJsonAsync<NewUsersViewModel>(cancellationToken: cancellationToken);

// public async Task<NewUsersViewModel> GetNewUsers(LeaderboardFilter filter, bool filterStaff = false, CancellationToken cancellationToken = default)
// {
// var result = await _httpClient.GetAsync($"{_baseRoute}/NewUsers?filter={filter}&filterStaff={filterStaff}");
//
// if (result.IsSuccessStatusCode)
// {
// var response = await result.Content.ReadFromJsonAsync<NewUsersViewModel>(cancellationToken: cancellationToken);
//
// if (response is not null)
// {
// return response;
// }
// }
//
// var responseContent = await result.Content.ReadAsStringAsync(cancellationToken);
//
// throw new Exception($"Failed to get user rewards: {responseContent}");
// }
if (response is not null)
{
return response;
}
}

var responseContent = await result.Content.ReadAsStringAsync(cancellationToken);

throw new Exception($"Failed to get new users: {responseContent}");
}
}
73 changes: 73 additions & 0 deletions src/Application/Users/Queries/GetNewUsers/GetNewUsersQuery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using AutoMapper.QueryableExtensions;
using SSW.Rewards.Application.Common.Extensions;
using SSW.Rewards.Shared.DTOs.Users;

namespace SSW.Rewards.Application.Users.Queries.GetNewUsers;

public class GetNewUsersQuery : IRequest<NewUsersViewModel>
{
public LeaderboardFilter Filter { get; set; }
public bool FilterStaff { get; set; }
}

public class GetNewUsersHandler(
IApplicationDbContext context,
IMapper mapper,
IDateTime dateTime)
: IRequestHandler<GetNewUsersQuery, NewUsersViewModel>
{
public async Task<NewUsersViewModel> Handle(GetNewUsersQuery request, CancellationToken cancellationToken)
{
var eligibleUsers = context.Users.AsNoTracking().Where(u => u.Activated == true);

switch (request.Filter)
{
case LeaderboardFilter.ThisYear:
eligibleUsers = eligibleUsers
.TagWith("NewUsersThisYear")
.Where(u => u.CreatedUtc.Year == dateTime.Now.Year);
break;
case LeaderboardFilter.ThisMonth:
eligibleUsers = eligibleUsers
.TagWith("NewUsersThisMonth")
.Where(u => u.CreatedUtc.Year == dateTime.Now.Year && u.CreatedUtc.Month == dateTime.Now.Month);
break;
case LeaderboardFilter.Today:
eligibleUsers = eligibleUsers
.TagWith("NewUsersToday")
.Where(u => u.CreatedUtc.Year == dateTime.Now.Year && u.CreatedUtc.Month == dateTime.Now.Month && u.CreatedUtc.Day == dateTime.Now.Day);
break;
case LeaderboardFilter.ThisWeek:
{
var start = dateTime.Now.FirstDayOfWeek();
var end = start.AddDays(7);

eligibleUsers = eligibleUsers
.TagWith("NewUsersThisWeek")
.Where(u => start <= u.CreatedUtc && u.CreatedUtc <= end);
break;
}
case LeaderboardFilter.Forever:
default:
// no action
break;
}

var users = await eligibleUsers
.ProjectTo<NewUserDto>(mapper.ConfigurationProvider)
.ToListAsync(cancellationToken);

// filter out any @ssw.com.au users (.ends with didn't translate with EF Core :(
if (request.FilterStaff)
{
users = users.Where(u => (u.Email != null && !u.Email.EndsWith("@ssw.com.au", StringComparison.OrdinalIgnoreCase))).ToList();
}

NewUsersViewModel vm = new()
{
NewUsers = users
};

return vm;
}
}
14 changes: 14 additions & 0 deletions src/Application/Users/Queries/GetNewUsers/Mapping.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using SSW.Rewards.Shared.DTOs.Users;

namespace SSW.Rewards.Application.Users.Queries;

public class Mapping : Profile
{
public Mapping()
{
CreateMap<User, NewUserDto>()
.ForMember(dst => dst.UserId, opt => opt.MapFrom(src => src.Id))
.ForMember(dst => dst.Name, opt => opt.MapFrom(src => src.FullName))
.ForMember(dst => dst.Email, opt => opt.MapFrom(src => src.Email));
}
}
10 changes: 10 additions & 0 deletions src/Common/DTOs/Users/NewUserDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace SSW.Rewards.Shared.DTOs.Users;

public class NewUserDto
{
public int? UserId { get; set; }

public string? Name { get; set; }

public string? Email { get; set; }
}
6 changes: 6 additions & 0 deletions src/Common/DTOs/Users/NewUsersViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SSW.Rewards.Shared.DTOs.Users;

public class NewUsersViewModel
{
public IEnumerable<NewUserDto> NewUsers { get; set; }
}
13 changes: 12 additions & 1 deletion src/WebAPI/Controllers/UserController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SSW.Rewards.Shared.DTOs.Users;
using SSW.Rewards.Application.Achievements.Commands.ClaimSocialMediaAchievementForUser;
using SSW.Rewards.Application.Users.Commands.DeleteMyProfile;
Expand All @@ -7,10 +8,13 @@
using SSW.Rewards.Application.Users.Commands.UpsertUserSocialMediaId;
using SSW.Rewards.Application.Users.Queries.GetCurrentUser;
using SSW.Rewards.Application.Users.Queries.GetCurrentUserRoles;
using SSW.Rewards.Application.Users.Queries.GetNewUsers;
using SSW.Rewards.Application.Users.Queries.GetProfileAchievements;
using SSW.Rewards.Application.Users.Queries.GetUser;
using SSW.Rewards.Application.Users.Queries.GetUserAchievements;
using SSW.Rewards.Application.Users.Queries.GetUserRewards;
using SSW.Rewards.Enums;
using SSW.Rewards.WebAPI.Authorisation;

namespace SSW.Rewards.WebAPI.Controllers;

Expand Down Expand Up @@ -93,4 +97,11 @@ public async Task<ActionResult> DeleteMyProfile()

return Ok();
}

[HttpGet]
[Authorize(Roles = AuthorizationRoles.Admin)]
public async Task<ActionResult<NewUsersViewModel>> GetNewUsers([FromQuery] LeaderboardFilter filter, bool filterStaff)
{
return await Mediator.Send(new GetNewUsersQuery { Filter = filter, FilterStaff = filterStaff });
}
}

0 comments on commit 124e72a

Please sign in to comment.