Skip to content
Open
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
19 changes: 15 additions & 4 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,11 @@ jobs:
fi

- name: Wait for Aspire Resources
timeout-minutes: 5
run: |
for attempt in {1..60}; do
if curl -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null &&
curl -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null; then
if curl --connect-timeout 5 --max-time 20 -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null &&
curl --connect-timeout 5 --max-time 20 -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null; then
break
fi

Expand All @@ -311,8 +312,8 @@ jobs:

- name: Verify E2E Endpoints
run: |
curl -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null
curl -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null
curl --connect-timeout 5 --max-time 30 -fksS https://web-ex.dev.localhost:7131/api/v2/about > /dev/null
curl --connect-timeout 5 --max-time 30 -fksS https://web-ex.dev.localhost:7131/next/login > /dev/null

- name: Run Playwright E2E Tests
working-directory: src/Exceptionless.Web/ClientApp
Expand All @@ -321,6 +322,16 @@ jobs:
E2E_RUN_ID: ci-${{ github.run_id }}-${{ github.run_attempt }}
run: npm run test:e2e:ci

- name: Capture E2E Failure Diagnostics
if: ${{ failure() }}
run: |
mkdir -p aspire-logs
for resource in Api App OldApp; do
timeout 30s aspire logs "$resource" --apphost src/Exceptionless.AppHost --tail 200 --timestamps --non-interactive > "aspire-logs/$resource.log" 2>&1 || true
done
free -m > aspire-logs/memory.log
sudo dmesg --ctime | grep -Ei 'out of memory|killed process|oom' >> aspire-logs/memory.log || true

- name: Stop AppHost
if: ${{ always() }}
run: |
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Exceptionless.Core.Attributes;
using Foundatio.Repositories.Models;

Expand All @@ -25,6 +26,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public ICollection<OAuthAccount> OAuthAccounts { get; init; } = new Collection<OAuthAccount>();
public ICollection<UserOrganizationPreference> OrganizationPreferences { get; init; } = new Collection<UserOrganizationPreference>();
public ICollection<UserSavedViewOrderPreference> SavedViewOrders { get; init; } = new Collection<UserSavedViewOrderPreference>();
public IDictionary<string, JsonElement> ProductTours { get; init; } = new Dictionary<string, JsonElement>(StringComparer.Ordinal);

/// <summary>
/// Gets or sets the users Full Name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Exceptionless.Core.Repositories;

public interface IUserRepository : ISearchableRepository<User>
{
Task<User?> RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc);
Task<bool> SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor<User>? options = null);
Task<User?> GetByEmailAddressAsync(string emailAddress);
Task<User?> GetByPasswordResetTokenAsync(string token);
Expand Down
35 changes: 35 additions & 0 deletions src/Exceptionless.Core/Repositories/UserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,48 @@ namespace Exceptionless.Core.Repositories;

public class UserRepository : RepositoryBase<User>, IUserRepository
{
private const int MaximumProductTourEntries = 100;

public UserRepository(ExceptionlessElasticConfiguration configuration, MiniValidationValidator validator, AppOptions options)
: base(configuration.Users, validator, options)
{
DefaultConsistency = Consistency.Immediate;
AddRequiredField(u => u.EmailAddress, u => u.OrganizationIds);
}

public async Task<User?> RecordProductTourAsync(User user, string stateKey, DateTime recordedUtc)
{
const string script = """
if (ctx._source.product_tours == null) {
ctx._source.product_tours = [:];
}
if (ctx._source.product_tours[params.key] instanceof String ||
(!ctx._source.product_tours.containsKey(params.key) && ctx._source.product_tours.size() >= params.maximum_entries)) {
ctx.op = 'none';
} else {
ctx._source.product_tours[params.key] = params.recorded_utc;
}
""";

await PatchAsync(user.Id, new ScriptPatch(script)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve tour progress across full user saves

When another user mutation begins before this patch and finishes afterward, its whole-document save can erase the newly recorded tour value. For example, UserHandler.Handle(UpdateUserMessage) reads a snapshot at line 196 and replaces it with SaveAsync at line 208; if this patch lands between those operations, the stale ProductTours dictionary is written back, so the completion or invitation acknowledgement disappears and the tour is offered again. Protect this field by merging the latest progress during full saves or by using optimistic concurrency with a retry.

AGENTS.md reference: AGENTS.md:L72-L75

Useful? React with 👍 / 👎.

{
Params = new Dictionary<string, object>
{
["key"] = stateKey,
["maximum_entries"] = MaximumProductTourEntries,
["recorded_utc"] = recordedUtc.ToString("O")
}
});
await Cache.RemoveAsync(EmailCacheKey(user.EmailAddress));

var updatedUser = await GetByIdAsync(user.Id, o => o.Cache(false));
// A concurrent writer may have advanced the document since this read; do not cache this snapshot.
if (updatedUser is not null)
await InvalidateCacheAsync(updatedUser);
Comment thread
ejsmith marked this conversation as resolved.

return updatedUser;
}

public Task<bool> SetSavedViewOrdersAsync(User user, CommandOptionsDescriptor<User>? options = null)
{
var savedViewOrders = user.SavedViewOrders.ToList();
Expand Down
16 changes: 16 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/UserEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ public static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder
}
});

group.MapPut("users/me/product-tours/{tourName}/record", async (string tourName, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper)
=> (await mediator.InvokeAsync<Result<RecordProductTourResult>>(new UserMessages.RecordCurrentUserProductTour(tourName))).ToHttpResult(resultMapper))
.Produces<RecordProductTourResult>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.ProducesProblem(StatusCodes.Status404NotFound)
.WithSummary("Record current user product tour")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
["tourName"] = "A UI-defined product tour identifier using lowercase letters, digits, and hyphens (up to 64 characters).",
},
ResponseDescriptions = new() {
["422"] = "The product tour name is invalid or the limit of 100 recorded product tour entries has been reached.",
["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
43 changes: 41 additions & 2 deletions src/Exceptionless.Web/Api/Handlers/UserHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
using Exceptionless.Web.Models.OAuth;
using Exceptionless.Web.Utility;
using Foundatio.Caching;
using Foundatio.Repositories;
using Foundatio.Mediator;
using Foundatio.Repositories;
using Foundatio.Repositories.Exceptions;

namespace Exceptionless.Web.Api.Handlers;

Expand All @@ -39,7 +40,8 @@ public class UserHandler(

public async Task<Result<ViewCurrentUser>> Handle(GetCurrentUser message)
{
var currentUser = await GetModelAsync(GetCurrentUserId());
// Preferences must reflect completed writes even if an in-flight lookup repopulates an older cache entry.
var currentUser = await GetModelAsync(GetCurrentUserId(), useCache: false);
if (currentUser is null)
return Result.NotFound("User not found.");

Expand All @@ -49,6 +51,43 @@ public async Task<Result<ViewCurrentUser>> Handle(GetCurrentUser message)
};
}

public async Task<Result<RecordProductTourResult>> Handle(RecordCurrentUserProductTour message)
{
if (message.TourName.Length is < 1 or > 64 || message.TourName.Any(c => !Char.IsAsciiLetterLower(c) && !Char.IsAsciiDigit(c) && c != '-'))
Comment thread
ejsmith marked this conversation as resolved.
{
return Result.Invalid(ValidationError.Create("tour_name", "Use lowercase letters, digits, and hyphens for the product tour name."));
}

var currentUser = await GetModelAsync(GetCurrentUserId());
if (currentUser is null)
{
return Result.NotFound("User not found.");
}

// Keep the existing JSON keys while letting the UI define new tour identifiers.
string stateKey = message.TourName.Replace('-', '_');
try
{
currentUser = await repository.RecordProductTourAsync(currentUser, stateKey, timeProvider.GetUtcNow().UtcDateTime);
}
catch (DocumentNotFoundException)
{
return Result.NotFound("User not found.");
}

if (currentUser is null)
{
return Result.NotFound("User not found.");
}

if (!currentUser.ProductTours.TryGetValue(stateKey, out var recorded))
{
return Result.Invalid(ValidationError.Create("tour_name", "The maximum number of recorded product tours has been reached."));
}

return new RecordProductTourResult(recorded.GetDateTime());
}

public async Task<Result<IReadOnlyCollection<ViewOAuthGrant>>> Handle(GetCurrentUserOAuthGrants message)
{
var tokens = new List<OAuthToken>();
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/Api/Messages/UserMessages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Exceptionless.Web.Api.Messages;
public record GetCurrentUser;
public record GetCurrentUserOAuthGrants;
public record RevokeCurrentUserOAuthGrant(string Id);
public record RecordCurrentUserProductTour(string TourName);
public record GetUserById(string Id);
public record GetUsersByOrganization(string OrganizationId, int Page, int Limit);
public record UpdateUserMessage(string Id, Delta<UpdateUser> Changes);
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Web/ClientApp/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
test-results
playwright-report
node_modules

# Output
Expand Down
Loading
Loading