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
6 changes: 6 additions & 0 deletions .agents/skills/frontend-architecture/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ import { User } from "$features/users/models"; // $lib/features
import { formatDate } from "$shared/formatters"; // $lib/features/shared
```

## Project Svelte Rules

- Prefer `$derived` for computed state and `$effect` for side effects.
- Use `untrack()` inside `$effect` when needed to avoid reactive loops.
- Prefer `kit-query-params` (`queryParamsState`) for route query parameter binding instead of ad-hoc URL parsing.

## Consistency Rule

**Before creating anything new, search the codebase for existing patterns.** Consistency is the most important quality of a codebase:
Expand Down
9 changes: 9 additions & 0 deletions .agents/skills/typescript-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ description: >
- **Minimize diffs**: Change only what's necessary, preserve existing formatting and structure
- Match surrounding code style exactly

## Project Frontend Rules

- Always use braces for all control flow statements (`if`, `for`, `while`, etc.).
- Always use block bodies for arrow functions that return statements; avoid single-expression shorthand in project code.
- Do not use abbreviations in identifiers (`organization`, not `org`; `filter`, not `filt`).
- Avoid inline single-line condition + return patterns; use multi-line blocks for readability.

## File Naming

- Use **kebab-case** for files and directories
Expand All @@ -36,6 +43,8 @@ import { formatDate, formatNumber } from "$lib/utils/formatters";
import * as utils from "$lib/utils";
```

- Named imports are preferred for most modules; namespace imports should be limited to approved patterns (e.g., shadcn composite imports).

### Allowed Namespace Imports

```typescript
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Core/Bootstrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddSingleton<IUserRepository, UserRepository>();
services.AddSingleton<IWebHookRepository, WebHookRepository>();
services.AddSingleton<ISavedViewRepository, SavedViewRepository>();
services.AddSingleton<ITokenRepository, TokenRepository>();

services.AddSingleton<IGeocodeService, NullGeocodeService>();
Expand Down
15 changes: 15 additions & 0 deletions src/Exceptionless.Core/Models/Organization.cs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ public Organization()
/// </summary>
public bool HasPremiumFeatures { get; set; }

/// <summary>
/// Set of enabled feature flags for this organization (e.g., "feature-saved-views").
/// Feature identifiers are always stored in lowercase.
/// </summary>
public ISet<string> Features { get; set; } = new HashSet<string>();

/// <summary>
/// Maximum number of users allowed by the current plan.
/// </summary>
Expand Down Expand Up @@ -176,3 +182,12 @@ public enum BillingStatus
Canceled = 3,
Unpaid = 4
}

/// <summary>
/// Well-known organization feature flag identifiers.
/// </summary>
public static class OrganizationFeatures
{
/// <summary>Enables the Saved Views feature for the organization.</summary>
public const string SavedViews = "feature-saved-views";
}
86 changes: 86 additions & 0 deletions src/Exceptionless.Core/Models/SavedView.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
using Exceptionless.Core.Attributes;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Models;

/// <summary>
/// A saved view captures filter, time range, and display settings for a dashboard page.
/// Org-scoped; optionally user-private when UserId is set.
/// </summary>
public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates
{
/// <summary>The set of valid dashboard view identifiers.</summary>
public static readonly string[] ValidViews = ["events", "issues", "stream"];

/// <summary>Valid column IDs per view, matching the TanStack Table column definitions.</summary>
public static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> ValidColumnIds =
new Dictionary<string, IReadOnlySet<string>>
{
["events"] = new HashSet<string> { "user", "date" },
["issues"] = new HashSet<string> { "status", "users", "events", "first", "last" },
["stream"] = new HashSet<string> { "user", "date" }
};

/// <summary>Union of all valid column IDs across all views.</summary>
public static readonly IReadOnlySet<string> AllValidColumnIds =
new HashSet<string>(ValidColumnIds.Values.SelectMany(ids => ids));

// Identity
[ObjectId]
public string Id { get; set; } = null!;

[ObjectId]
[Required]
public string OrganizationId { get; set; } = null!;

// User associations
/// <summary>When set, this view is private to the specified user. Null means org-wide.</summary>
[ObjectId]
public string? UserId { get; set; }

/// <summary>The user who originally created this view.</summary>
[ObjectId]
[Required]
public string CreatedByUserId { get; set; } = null!;

/// <summary>The user who last modified this view.</summary>
[ObjectId]
public string? UpdatedByUserId { get; set; }

// View configuration
/// <summary>Raw Lucene filter query string, e.g. "(status:open OR status:regressed)". Null means no filter (show all).</summary>
[MaxLength(2000)]
public string? Filter { get; set; }

/// <summary>JSON array of structured filter objects for UI chip hydration.</summary>
[MaxLength(10000)]
public string? FilterDefinitions { get; set; }

/// <summary>Column visibility state per dashboard table, keyed by column id.</summary>
public Dictionary<string, bool>? Columns { get; set; }

/// <summary>Whether this view loads automatically when navigating to the page.</summary>
public bool IsDefault { get; set; }

/// <summary>Display name shown in the sidebar and picker.</summary>
[Required]
[MaxLength(100)]
public string Name { get; set; } = null!;

/// <summary>Date-math time range, e.g. "[now-7d TO now]". Null if no time constraint.</summary>
[MaxLength(100)]
public string? Time { get; set; }

/// <summary>Schema version for future filter definition migrations.</summary>
public int Version { get; set; } = 1;

/// <summary>Dashboard page identifier: "events", "issues", or "stream".</summary>
[Required]
[RegularExpression("^(events|issues|stream)$")]
public string View { get; set; } = null!;

// Timestamps
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ ILoggerFactory loggerFactory
AddIndex(Migrations = new MigrationIndex(this, _appOptions.ElasticsearchOptions.ScopePrefix + "migrations", appOptions.ElasticsearchOptions.NumberOfReplicas));
AddIndex(Organizations = new OrganizationIndex(this));
AddIndex(Projects = new ProjectIndex(this));
AddIndex(SavedViews = new SavedViewIndex(this));
AddIndex(Tokens = new TokenIndex(this));
AddIndex(Users = new UserIndex(this));
AddIndex(WebHooks = new WebHookIndex(this));
Expand Down Expand Up @@ -71,6 +72,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder)
public MigrationIndex Migrations { get; }
public OrganizationIndex Organizations { get; }
public ProjectIndex Projects { get; }
public SavedViewIndex SavedViews { get; }
public TokenIndex Tokens { get; }
public UserIndex Users { get; }
public WebHookIndex WebHooks { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public override TypeMappingDescriptor<Organization> ConfigureIndexMapping(TypeMa
.Text(f => f.Name(e => e.Name).AddKeywordField())
.Keyword(f => f.Name(u => u.StripeCustomerId))
.Boolean(f => f.Name(u => u.HasPremiumFeatures))
.Keyword(f => f.Name(u => u.Features))
.Keyword(f => f.Name(u => u.PlanId))
.Keyword(f => f.Name(u => u.PlanName).IgnoreAbove(256))
.Date(f => f.Name(u => u.SubscribeDate))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Foundatio.Repositories.Elasticsearch.Configuration;
using Foundatio.Repositories.Elasticsearch.Extensions;
using Nest;

namespace Exceptionless.Core.Repositories.Configuration;

public sealed class SavedViewIndex : VersionedIndex<Models.SavedView>
{
internal const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase";

private readonly ExceptionlessElasticConfiguration _configuration;

public SavedViewIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "saved-views", 1)
{
_configuration = configuration;
}

public override TypeMappingDescriptor<Models.SavedView> ConfigureIndexMapping(TypeMappingDescriptor<Models.SavedView> map)
{
return map
.Dynamic(false)
.Properties(p => p
.SetupDefaults()
.Keyword(f => f.Name(e => e.OrganizationId))
.Keyword(f => f.Name(e => e.UserId))
.Keyword(f => f.Name(e => e.CreatedByUserId))
.Keyword(f => f.Name(e => e.UpdatedByUserId))
.Text(f => f.Name(e => e.Name).Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField())
.Keyword(f => f.Name(e => e.View))
.Boolean(f => f.Name(e => e.IsDefault))
.Number(f => f.Name(e => e.Version).Type(NumberType.Integer)));
}

public override CreateIndexDescriptor ConfigureIndex(CreateIndexDescriptor idx)
{
return base.ConfigureIndex(idx.Settings(s => s
.Analysis(d => d.Analyzers(b => b.Custom(KEYWORD_LOWERCASE_ANALYZER, c => c.Filters("lowercase").Tokenizer("keyword"))))
.NumberOfShards(_configuration.Options.NumberOfShards)
.NumberOfReplicas(_configuration.Options.NumberOfReplicas)
.Priority(5)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Exceptionless.Core.Models;
using Foundatio.Repositories;
using Foundatio.Repositories.Models;

namespace Exceptionless.Core.Repositories;

public interface ISavedViewRepository : IRepositoryOwnedByOrganization<SavedView>
{
Task<FindResults<SavedView>> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor<SavedView>? options = null);
Task<FindResults<SavedView>> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor<SavedView>? options = null);
Task<FindResults<SavedView>> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor<SavedView>? options = null);
Task<long> CountByOrganizationIdAsync(string organizationId);

/// <summary>Removes all private saved views belonging to a specific user within an organization.</summary>
Task<long> RemovePrivateByUserIdAsync(string organizationId, string userId);
}
60 changes: 60 additions & 0 deletions src/Exceptionless.Core/Repositories/SavedViewRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
using Exceptionless.Core.Models;
using Exceptionless.Core.Repositories.Configuration;
using Foundatio.Repositories;
using Foundatio.Repositories.Models;
using Nest;

namespace Exceptionless.Core.Repositories;

public class SavedViewRepository : RepositoryOwnedByOrganization<SavedView>, ISavedViewRepository
{
public SavedViewRepository(ExceptionlessElasticConfiguration configuration, AppOptions options)
: base(configuration.SavedViews, null!, options)
{
}

public Task<FindResults<SavedView>> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor<SavedView>? options = null)
{
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
&& Query<SavedView>.Term(e => e.View, view);

return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options);
}

public Task<FindResults<SavedView>> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor<SavedView>? options = null)
{
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
&& Query<SavedView>.Term(e => e.View, view)
&& (!Query<SavedView>.Exists(e => e.Field(f => f.UserId))
|| Query<SavedView>.Term(e => e.UserId, userId));

return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options);
}

public Task<FindResults<SavedView>> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor<SavedView>? options = null)
{
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
&& (!Query<SavedView>.Exists(e => e.Field(f => f.UserId))
|| Query<SavedView>.Term(e => e.UserId, userId));

return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options);
}

public async Task<long> RemovePrivateByUserIdAsync(string organizationId, string userId)
{
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
&& Query<SavedView>.Term(e => e.UserId, userId);

var results = await FindAsync(q => q.ElasticFilter(filter), o => o.PageLimit(1000));
if (results.Total == 0)
return 0;

await RemoveAsync(results.Documents);
return results.Total;
}

public async Task<long> CountByOrganizationIdAsync(string organizationId)
{
return await CountAsync(q => q.Organization(organizationId));
}
}
18 changes: 17 additions & 1 deletion src/Exceptionless.Core/Services/OrganizationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ public class OrganizationService : IStartupAction
private const int BATCH_SIZE = 50;
private readonly IOrganizationRepository _organizationRepository;
private readonly IProjectRepository _projectRepository;
private readonly ISavedViewRepository _savedViewRepository;
private readonly ITokenRepository _tokenRepository;
private readonly IUserRepository _userRepository;
private readonly IWebHookRepository _webHookRepository;
private readonly AppOptions _appOptions;
private readonly UsageService _usageService;
private readonly ILogger _logger;

public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, AppOptions appOptions, UsageService usageService, ILoggerFactory loggerFactory)
public OrganizationService(IOrganizationRepository organizationRepository, IProjectRepository projectRepository, ISavedViewRepository savedViewRepository, ITokenRepository tokenRepository, IUserRepository userRepository, IWebHookRepository webHookRepository, AppOptions appOptions, UsageService usageService, ILoggerFactory loggerFactory)
{
_organizationRepository = organizationRepository;
_projectRepository = projectRepository;
_savedViewRepository = savedViewRepository;
_tokenRepository = tokenRepository;
_userRepository = userRepository;
_webHookRepository = webHookRepository;
Expand Down Expand Up @@ -180,13 +182,27 @@ public Task<long> RemoveWebHooksAsync(Organization organization)
return _webHookRepository.RemoveAllByOrganizationIdAsync(organization.Id);
}

public Task<long> RemoveSavedViewsAsync(Organization organization)
{
_logger.LogInformation("Removing saved views for {OrganizationName} ({OrganizationId})", organization.Name, organization.Id);
return _savedViewRepository.RemoveAllByOrganizationIdAsync(organization.Id);
}

/// <summary>Removes all private saved views for a user leaving an organization. Org-wide views created by that user are preserved.</summary>
public Task<long> RemoveUserSavedViewsAsync(string organizationId, string userId)
{
_logger.LogInformation("Removing private saved views for user {UserId} from organization {OrganizationId}", userId, organizationId);
return _savedViewRepository.RemovePrivateByUserIdAsync(organizationId, userId);
}

public async Task SoftDeleteOrganizationAsync(Organization organization, string currentUserId)
{
if (organization.IsDeleted)
return;

await RemoveTokensAsync(organization);
await RemoveWebHooksAsync(organization);
await RemoveSavedViewsAsync(organization);
await CancelSubscriptionsAsync(organization);
await RemoveUsersAsync(organization, currentUserId);
await CleanupProjectNotificationSettingsAsync(organization, []);
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/ClientApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,4 @@
"overrides": {
"storybook": "$storybook"
}
}
}
Loading
Loading