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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 0 additions & 22 deletions src/Exceptionless.Core/Models/Data/ProductTourProgress.cs

This file was deleted.

2 changes: 0 additions & 2 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using Exceptionless.Core.Attributes;
using Exceptionless.Core.Models.Data;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Models;
Expand All @@ -24,7 +23,6 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public string? PasswordResetToken { get; set; }
public DateTime PasswordResetTokenExpiration { get; set; }
public ICollection<OAuthAccount> OAuthAccounts { get; init; } = new Collection<OAuthAccount>();
public IDictionary<string, ProductTourProgress> ProductTours { get; init; } = new Dictionary<string, ProductTourProgress>(StringComparer.Ordinal);

/// <summary>
/// Gets or sets the users Full Name.
Expand Down
19 changes: 0 additions & 19 deletions src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,25 +37,6 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder
}
});

group.MapPut("users/me/product-tours/{tourId:minlength(1):maxlength(64)}", async (string tourId, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper, [FromBody] UpdateProductTourProgress? progress)
=> progress is null ? ApiValidation.MissingRequestBody() : (await mediator.InvokeAsync<Result<ViewCurrentUser>>(new UserMessages.UpdateCurrentUserProductTour(tourId, progress))).ToHttpResult(resultMapper))
.Accepts<UpdateProductTourProgress>(false, "application/json", "application/*+json")
.Produces<ViewCurrentUser>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status404NotFound)
.WithSummary("Update current user product tour progress")
.WithMetadata(new EndpointDocumentation {
RequestBodyDescription = "The versioned product tour outcome.",
RequestBodyRequired = true,
ParameterDescriptions = new() {
["tourId"] = "The stable product tour identifier.",
},
ResponseDescriptions = new() {
["422"] = "The product tour progress is invalid.",
["404"] = "The current user could not be found.",
}
});

group.MapGet("users/me/oauth-grants", async (IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper)
=> (await mediator.InvokeAsync<Result<IReadOnlyCollection<ViewOAuthGrant>>>(new UserMessages.GetCurrentUserOAuthGrants())).ToHttpResult(resultMapper))
.Produces<IReadOnlyCollection<ViewOAuthGrant>>()
Expand Down
54 changes: 0 additions & 54 deletions src/Exceptionless.Web/Api/Handlers/UserHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using Exceptionless.Core.Extensions;
using Exceptionless.Core.Mail;
using Exceptionless.Core.Models;
using Exceptionless.Core.Models.Data;
using Exceptionless.Core.Repositories;
using Exceptionless.DateTimeExtensions;
using Exceptionless.Web.Api.Infrastructure;
Expand All @@ -15,10 +14,8 @@
using Exceptionless.Web.Models.OAuth;
using Exceptionless.Web.Utility;
using Foundatio.Caching;
using Foundatio.Lock;
using Foundatio.Repositories;
using Foundatio.Mediator;
using System.Text.RegularExpressions;

namespace Exceptionless.Web.Api.Handlers;

Expand All @@ -29,16 +26,13 @@ public class UserHandler(
IOAuthTokenRepository oauthTokenRepository,
IOAuthApplicationRepository oauthApplicationRepository,
ICacheClient cacheClient,
ILockProvider lockProvider,
IMailer mailer,
ApiMapper mapper,
IntercomOptions intercomOptions,
TimeProvider timeProvider,
IHttpContextAccessor httpContextAccessor,
ILoggerFactory loggerFactory)
{
private const int MaximumProductTours = 32;
private static readonly Regex ProductTourIdRegex = new("^[a-z0-9]+(?:-[a-z0-9]+)*$", RegexOptions.CultureInvariant);
private readonly ICacheClient _cache = new ScopedCacheClient(cacheClient, "User");
private readonly ILogger _logger = loggerFactory.CreateLogger<UserHandler>();
private HttpContext HttpContext => httpContextAccessor.HttpContext ?? throw new InvalidOperationException("HttpContext is unavailable.");
Expand All @@ -55,54 +49,6 @@ public async Task<Result<ViewCurrentUser>> Handle(GetCurrentUser message)
};
}

public async Task<Result<ViewCurrentUser>> Handle(UpdateCurrentUserProductTour message)
{
if (!ProductTourIdRegex.IsMatch(message.TourId))
return Result.Invalid(ValidationError.Create("tour_id", "Tour id can only contain lowercase letters, numbers, and single dashes."));

string currentUserId = GetCurrentUserId();
Result<ViewCurrentUser>? result = null;
bool lockAcquired = await lockProvider.TryUsingAsync($"product-tours:{currentUserId}", async () =>
{
result = await UpdateCurrentUserProductTourAsync(currentUserId, message);
}, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10));

return lockAcquired && result is not null ? result : Result.Error("Unable to update product tour progress.");
}

private async Task<Result<ViewCurrentUser>> UpdateCurrentUserProductTourAsync(string currentUserId, UpdateCurrentUserProductTour message)
{
var currentUser = await GetModelAsync(currentUserId, useCache: false);
if (currentUser is null)
return Result.NotFound("User not found.");

bool isNewTour = !currentUser.ProductTours.TryGetValue(message.TourId, out var existingProgress);
if (isNewTour && currentUser.ProductTours.Count >= MaximumProductTours)
return Result.Invalid(ValidationError.Create("tour_id", $"A user cannot track more than {MaximumProductTours} product tours."));

var requestedStatus = message.Progress.Status!.Value;
if (existingProgress is null
|| message.Progress.Version > existingProgress.Version
|| (message.Progress.Version == existingProgress.Version
&& existingProgress.Status == ProductTourStatus.Dismissed
&& requestedStatus == ProductTourStatus.Completed))
{
currentUser.ProductTours[message.TourId] = new ProductTourProgress
{
Version = message.Progress.Version,
Status = requestedStatus,
UpdatedUtc = timeProvider.GetUtcNow().UtcDateTime
};

await repository.SaveAsync(currentUser, o => o.Cache());
}

return new ViewCurrentUser(currentUser, intercomOptions)
{
AvatarUrl = GetUserAvatarUrl(currentUser.Id, currentUser.AvatarFileName)
};
}

public async Task<Result<IReadOnlyCollection<ViewOAuthGrant>>> Handle(GetCurrentUserOAuthGrants message)
{
var tokens = new List<OAuthToken>();
Expand Down
1 change: 0 additions & 1 deletion src/Exceptionless.Web/Api/Messages/UserMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ namespace Exceptionless.Web.Api.Messages;
public record GetCurrentUser;
public record GetCurrentUserOAuthGrants;
public record RevokeCurrentUserOAuthGrant(string Id);
public record UpdateCurrentUserProductTour(string TourId, UpdateProductTourProgress Progress);
public record GetUserById(string Id);
public record GetUsersByOrganization(string OrganizationId, int Page, int Limit);
public record UpdateUserMessage(string Id, Delta<UpdateUser> Changes);
Expand Down
9 changes: 0 additions & 9 deletions src/Exceptionless.Web/ClientApp/e2e/fixtures/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,15 +301,6 @@ export class E2EApiClient {
await expectStatus(response, [202], 'submit event');
}

async updateProductTour(token: string, tourId: string, version: number, status: 'completed' | 'dismissed'): Promise<void> {
const response = await this.request.put(this.url(`users/me/product-tours/${tourId}`), {
data: { status, version },
headers: this.authHeaders(token)
});

await expectStatus(response, [200], 'update product tour');
}

async waitForCurrentUserDeleted(token: string, timeoutMs = 30_000): Promise<void> {
await waitForCondition(
async () => !(await this.getCurrentUser(token)),
Expand Down
1 change: 0 additions & 1 deletion src/Exceptionless.Web/ClientApp/e2e/fixtures/e2e-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ export const test = base.extend<E2EFixtures>({
const project = await e2eApi.createProject(userToken, organization.id, projectName);
projectId = project.id;
const projectToken = await e2eApi.getProjectDefaultToken(userToken, project.id);
await e2eApi.updateProductTour(userToken, 'welcome', 1, 'dismissed');

await page.addInitScript(
({ organizationId, token }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,18 +219,6 @@ export class ExceptionlessE2EJourney {

await expect(setupHeading).toBeVisible({ timeout: 30_000 });

const welcomeDialog = this.page.getByRole('dialog', { name: 'Welcome to the new Exceptionless UI' });
await expect(welcomeDialog).toBeVisible({ timeout: 30_000 });
const skippedResponse = this.page.waitForResponse(
(response) => response.request().method() === 'PUT' && response.url().includes('/api/v2/users/me/product-tours/welcome')
);
await welcomeDialog.getByRole('button', { name: 'Skip' }).click();
expect((await skippedResponse).ok()).toBe(true);
await expect(welcomeDialog).toBeHidden();
await this.page.reload();
await expect(setupHeading).toBeVisible();
await expect(welcomeDialog).toBeHidden();

await this.page.getByLabel('Organization Name', { exact: true }).fill(this.organizationName);
await this.page.getByLabel('Project Name', { exact: true }).fill(this.projectName);
await this.page.getByRole('button', { name: 'Continue' }).click();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -363,18 +363,13 @@ function recordRequest(diagnostics: RuntimeDiagnostics, request: Request, organi
}

function recordRequestFailure(diagnostics: RuntimeDiagnostics, request: Request): void {
const path = new URL(request.url()).pathname;
const error = request.failure()?.errorText ?? null;
const isDeliberateRemount = diagnostics.activeAction.endsWith('route remounts');
const isCanceledProjectRead = request.method() === 'GET' && /^\/api\/v2\/projects\/[a-f0-9]{24}$/.test(path) && error === 'net::ERR_ABORTED';
// Repeated page.goto calls intentionally tear down detail observers; Chromium reports their superseded project reads as aborted.
if (!path.startsWith('/api/v2/') || (isDeliberateRemount && isCanceledProjectRead)) {
if (!new URL(request.url()).pathname.startsWith('/api/v2/')) {
return;
}

diagnostics.requestFailures.push({
action: diagnostics.activeAction,
error,
error: request.failure()?.errorText ?? null,
method: request.method(),
url: request.url()
});
Expand Down
Loading
Loading