Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1c7797a
Add saved view home defaults
ejsmith Aug 23, 2026
db91d85
Update endpoint manifest snapshot
ejsmith Aug 23, 2026
07b90ec
Align navigation tests with saved view defaults
ejsmith Aug 23, 2026
6068f27
Address saved view default review feedback
ejsmith Aug 23, 2026
18a1b92
Allow global admin personal saved view defaults
ejsmith Aug 23, 2026
d6cb744
Clear saved view defaults on organization deletion
ejsmith Aug 23, 2026
3391ffe
Refresh saved view default cache entries
ejsmith Aug 23, 2026
90de53b
Make saved view default cleanup recoverable
ejsmith Aug 23, 2026
f40737f
Serialize saved view default mutations
ejsmith Aug 23, 2026
8d0b296
Harden saved view default synchronization
ejsmith Aug 24, 2026
3478d08
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
c93b140
Document saved view delete conflicts
ejsmith Aug 24, 2026
ccd20a7
Patch saved view defaults atomically
ejsmith Aug 24, 2026
326233e
Detect stale user and organization saves
ejsmith Aug 24, 2026
44eec9e
Resolve duplicate saved view preferences
ejsmith Aug 24, 2026
e74a2b9
Serialize saved view defaults with membership changes
ejsmith Aug 24, 2026
95fc529
Make saved view default cleanup side effect free
ejsmith Aug 24, 2026
8491d73
Serialize organization deletion with saved view defaults
ejsmith Aug 24, 2026
68a546b
Preserve optimistic versions through repository caching
ejsmith Aug 24, 2026
ac334b6
Make organization deletion conflict safe
ejsmith Aug 24, 2026
f9e33a8
Allow organization deletion cleanup to resume
ejsmith Aug 24, 2026
a3cd0a4
Update versioned entity API contract
ejsmith Aug 24, 2026
08057cf
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
a7c28f0
Make versioned organization operations conflict safe
ejsmith Aug 24, 2026
2afd291
Harden versioned saved view operations
ejsmith Aug 24, 2026
36d05e9
Make billing persistence conflict safe
ejsmith Aug 24, 2026
adbe4d3
Simplify saved view default persistence
ejsmith Aug 24, 2026
b5bd198
Preserve saved view default consistency
ejsmith Aug 24, 2026
89ddb4e
Merge remote-tracking branch 'origin/main' into feature/saved-view-ho…
ejsmith Aug 24, 2026
eaeb0a6
Clean up saved view defaults on organization deletion
ejsmith Aug 24, 2026
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
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Models/Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public Organization()
[Required]
public string Name { get; set; } = null!;

/// <summary>
/// The shared saved view used as the default landing view when a user has not selected a personal default.
/// </summary>
[ObjectId]
public string? DefaultSavedViewId { get; set; }

[StringLength(2000)]
public string? IconFileName { get; set; }

Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ 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 ICollection<UserOrganizationPreference> OrganizationPreferences { get; init; } = new Collection<UserOrganizationPreference>();

/// <summary>
/// Gets or sets the users Full Name.
Expand Down
13 changes: 13 additions & 0 deletions src/Exceptionless.Core/Models/UserOrganizationPreference.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Exceptionless.Core.Attributes;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Models;

public sealed record UserOrganizationPreference
{
[ObjectId]
public string OrganizationId { get; set; } = null!;

[ObjectId]
public string DefaultSavedViewId { get; set; } = null!;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public sealed class OrganizationIndex : VersionedIndex<Organization>
private const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase";
private readonly ExceptionlessElasticConfiguration _configuration;

public OrganizationIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "organizations", 3)
public OrganizationIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "organizations", 4)
{
_configuration = configuration;
}
Expand All @@ -25,6 +25,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor<Organization> m
.Properties(p => p
.SetupDefaults()
.Text(e => e.Name, t => t.AddKeywordField())
.Keyword(e => e.DefaultSavedViewId)
.Keyword(e => e.StripeCustomerId)
.Boolean(e => e.HasPremiumFeatures)
.Keyword(e => e.Features)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public sealed class UserIndex : VersionedIndex<User>
private const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase";
private readonly ExceptionlessElasticConfiguration _configuration;

public UserIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "users", 1)
public UserIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "users", 2)
{
_configuration = configuration;
}
Expand All @@ -32,6 +32,9 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor<User> map)
.Keyword(e => e.PasswordResetToken)
.Date(e => e.PasswordResetTokenExpiration)
.Keyword(e => e.Roles)
.Object(e => e.OrganizationPreferences, o => o.Properties(mp => mp
.Keyword("organization_id")
.Keyword("default_saved_view_id")))
.Object(e => e.OAuthAccounts, o => o.Properties(mp => mp
.Keyword("provider")
.Keyword("provider_user_id")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ public interface IUserRepository : ISearchableRepository<User>
Task<User?> GetUserByOAuthProviderAsync(string provider, string providerUserId);
Task<User?> GetByVerifyEmailAddressTokenAsync(string token);
Task<FindResults<User>> GetByOrganizationIdAsync(string organizationId, CommandOptionsDescriptor<User>? options = null);
Task<FindResults<User>> GetByOrganizationPreferenceIdAsync(string organizationId, CommandOptionsDescriptor<User>? options = null);
Task<FindResults<User>> GetByDefaultSavedViewIdAsync(string savedViewId, CommandOptionsDescriptor<User>? options = null);
}
16 changes: 16 additions & 0 deletions src/Exceptionless.Core/Repositories/UserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,22 @@ public Task<FindResults<User>> GetByOrganizationIdAsync(string organizationId, C
return FindAsync(q => q.FieldEquals(u => u.OrganizationIds, organizationId).SortAscending(u => u.EmailAddress), o => commandOptions);
}

public Task<FindResults<User>> GetByDefaultSavedViewIdAsync(string savedViewId, CommandOptionsDescriptor<User>? options = null)
{
if (String.IsNullOrEmpty(savedViewId))
return Task.FromResult(new FindResults<User>());

return FindAsync(q => q.FieldEquals(u => u.OrganizationPreferences.First().DefaultSavedViewId, savedViewId), options);
}

public Task<FindResults<User>> GetByOrganizationPreferenceIdAsync(string organizationId, CommandOptionsDescriptor<User>? options = null)
{
if (String.IsNullOrEmpty(organizationId))
return Task.FromResult(new FindResults<User>());

return FindAsync(q => q.FieldEquals(u => u.OrganizationPreferences.First().OrganizationId, organizationId), options);
}

protected override async Task AddDocumentsToCacheAsync(ICollection<FindHit<User>> findHits, ICommandOptions options, bool isDirtyRead)
{
await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead);
Expand Down
18 changes: 18 additions & 0 deletions src/Exceptionless.Core/Services/OrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ public async Task<long> RemoveUsersAsync(Organization organization, string? curr
{
_logger.LogInformation("Removing user {User} from organization: {OrganizationName} ({Organization})", user.Id, organization.Name, organization.Id);
user.OrganizationIds.Remove(organization.Id);
foreach (var preference in user.OrganizationPreferences.Where(preference => String.Equals(preference.OrganizationId, organization.Id, StringComparison.Ordinal)).ToList())
user.OrganizationPreferences.Remove(preference);
Comment thread
ejsmith marked this conversation as resolved.
Comment thread
ejsmith marked this conversation as resolved.
usersToUpdate.Add(user);
}
}
Expand All @@ -109,6 +111,22 @@ public async Task<long> RemoveUsersAsync(Organization organization, string? curr
break;
}

var preferenceResults = await _userRepository.GetByOrganizationPreferenceIdAsync(organization.Id, o => o.SearchAfterPaging().PageLimit(BATCH_SIZE));
Comment thread
ejsmith marked this conversation as resolved.
while (preferenceResults.Documents.Count > 0)
{
foreach (var user in preferenceResults.Documents)
{
foreach (var preference in user.OrganizationPreferences.Where(preference => String.Equals(preference.OrganizationId, organization.Id, StringComparison.Ordinal)).ToList())
user.OrganizationPreferences.Remove(preference);
}

await _userRepository.SaveAsync(preferenceResults.Documents, o => o.Cache());
totalUsersAffected += preferenceResults.Documents.Count;

if (!await preferenceResults.NextPageAsync())
break;
}

return totalUsersAffected;
}

Expand Down
57 changes: 57 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/SavedViewEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,63 @@ public static IEndpointRouteBuilder MapSavedViewEndpoints(this IEndpointRouteBui
}
});

group.MapGet("organizations/{organizationId:objectid}/saved-view-defaults", async (string organizationId, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper)
Comment thread
ejsmith marked this conversation as resolved.
=> (await mediator.InvokeAsync<Result<ViewSavedViewDefaults>>(new SavedViewMessages.GetSavedViewDefaults(organizationId))).ToHttpResult(resultMapper))
.Produces<ViewSavedViewDefaults>()
.ProducesProblem(StatusCodes.Status404NotFound)
.WithSummary("Get saved view defaults")
.WithMetadata(new EndpointDocumentation {
ParameterDescriptions = new() {
["organizationId"] = "The identifier of the organization.",
},
ResponseDescriptions = new() {
["200"] = "The current user's and organization's accessible saved view defaults.",
["404"] = "The organization could not be found.",
}
});

group.MapPut("organizations/{organizationId:objectid}/saved-view-defaults/user", async (string organizationId, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper,
[FromBody] UpdateSavedViewDefault savedViewDefault)
=> (await mediator.InvokeAsync<Result<ViewSavedViewDefaults>>(new SavedViewMessages.UpdateUserSavedViewDefault(organizationId, savedViewDefault))).ToHttpResult(resultMapper))
.Accepts<UpdateSavedViewDefault>("application/json", "application/*+json")
.Produces<ViewSavedViewDefaults>()
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
Comment thread
ejsmith marked this conversation as resolved.
.WithSummary("Update the current user's saved view default")
.WithMetadata(new EndpointDocumentation {
RequestBodyDescription = "The personal saved view default. A null saved view identifier clears the preference.",
RequestBodyRequired = true,
ParameterDescriptions = new() {
["organizationId"] = "The identifier of the organization.",
},
ResponseDescriptions = new() {
["200"] = "The personal saved view default was updated.",
["404"] = "The organization could not be found.",
["422"] = "The saved view is not accessible in this organization.",
}
});

group.MapPut("organizations/{organizationId:objectid}/saved-view-defaults/organization", async (string organizationId, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper,
[FromBody] UpdateSavedViewDefault savedViewDefault)
=> (await mediator.InvokeAsync<Result<ViewSavedViewDefaults>>(new SavedViewMessages.UpdateOrganizationSavedViewDefault(organizationId, savedViewDefault))).ToHttpResult(resultMapper))
.Accepts<UpdateSavedViewDefault>("application/json", "application/*+json")
.Produces<ViewSavedViewDefaults>()
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithSummary("Update the organization's saved view default")
.WithMetadata(new EndpointDocumentation {
RequestBodyDescription = "The shared saved view default. A null saved view identifier clears the preference.",
RequestBodyRequired = true,
ParameterDescriptions = new() {
["organizationId"] = "The identifier of the organization.",
},
ResponseDescriptions = new() {
["200"] = "The organization saved view default was updated.",
["404"] = "The organization could not be found.",
["422"] = "The saved view is private or is not accessible in this organization.",
}
});

group.MapPost("organizations/{organizationId:objectid}/saved-views", async (string organizationId, IMediator mediator, IMediatorResultMapper<HttpIResult> resultMapper,
[FromBody] NewSavedView savedView) =>
{
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Web/Api/Handlers/OrganizationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,8 @@ public async Task<Result> Handle(RemoveOrganizationUser message)
await organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id);

user.OrganizationIds.Remove(organization.Id);
foreach (var preference in user.OrganizationPreferences.Where(preference => String.Equals(preference.OrganizationId, organization.Id, StringComparison.Ordinal)).ToList())
user.OrganizationPreferences.Remove(preference);
await userRepository.SaveAsync(user, o => o.Cache());
await messagePublisher.PublishAsync(new UserMembershipChanged
{
Expand Down
Loading