Skip to content

Commit f8028c4

Browse files
committed
feat(saved-views): add organization and private saved views across API and UI
Implements saved views end-to-end with repository/index support, API endpoints, and Svelte integration for events/issues/stream dashboards. Adds coverage for controller behavior and Mapperly mappings, including organization-wide vs private visibility and default-view behavior.
1 parent 6db0131 commit f8028c4

42 files changed

Lines changed: 5467 additions & 28 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/frontend-architecture/SKILL.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,12 @@ import { User } from "$features/users/models"; // $lib/features
218218
import { formatDate } from "$shared/formatters"; // $lib/features/shared
219219
```
220220

221+
## Project Svelte Rules
222+
223+
- Prefer `$derived` for computed state and `$effect` for side effects.
224+
- Use `untrack()` inside `$effect` when needed to avoid reactive loops.
225+
- Prefer `kit-query-params` (`queryParamsState`) for route query parameter binding instead of ad-hoc URL parsing.
226+
221227
## Consistency Rule
222228

223229
**Before creating anything new, search the codebase for existing patterns.** Consistency is the most important quality of a codebase:

.agents/skills/typescript-conventions/SKILL.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ description: >
1616
- **Minimize diffs**: Change only what's necessary, preserve existing formatting and structure
1717
- Match surrounding code style exactly
1818

19+
## Project Frontend Rules
20+
21+
- Always use braces for all control flow statements (`if`, `for`, `while`, etc.).
22+
- Always use block bodies for arrow functions that return statements; avoid single-expression shorthand in project code.
23+
- Do not use abbreviations in identifiers (`organization`, not `org`; `filter`, not `filt`).
24+
- Avoid inline single-line condition + return patterns; use multi-line blocks for readability.
25+
1926
## File Naming
2027

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

46+
- Named imports are preferred for most modules; namespace imports should be limited to approved patterns (e.g., shadcn composite imports).
47+
3948
### Allowed Namespace Imports
4049

4150
```typescript

src/Exceptionless.Core/Bootstrapper.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO
153153
services.AddSingleton<IProjectRepository, ProjectRepository>();
154154
services.AddSingleton<IUserRepository, UserRepository>();
155155
services.AddSingleton<IWebHookRepository, WebHookRepository>();
156+
services.AddSingleton<ISavedViewRepository, SavedViewRepository>();
156157
services.AddSingleton<ITokenRepository, TokenRepository>();
157158

158159
services.AddSingleton<IGeocodeService, NullGeocodeService>();

src/Exceptionless.Core/Models/Organization.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ public Organization()
130130
/// </summary>
131131
public bool HasPremiumFeatures { get; set; }
132132

133+
/// <summary>
134+
/// Set of enabled feature flags for this organization (e.g., "feature-saved-views").
135+
/// Feature identifiers are always stored in lowercase.
136+
/// </summary>
137+
public ISet<string> Features { get; set; } = new HashSet<string>();
138+
133139
/// <summary>
134140
/// Maximum number of users allowed by the current plan.
135141
/// </summary>
@@ -176,3 +182,12 @@ public enum BillingStatus
176182
Canceled = 3,
177183
Unpaid = 4
178184
}
185+
186+
/// <summary>
187+
/// Well-known organization feature flag identifiers.
188+
/// </summary>
189+
public static class OrganizationFeatures
190+
{
191+
/// <summary>Enables the Saved Views feature for the organization.</summary>
192+
public const string SavedViews = "feature-saved-views";
193+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using System.ComponentModel.DataAnnotations;
2+
using Exceptionless.Core.Attributes;
3+
using Foundatio.Repositories.Models;
4+
5+
namespace Exceptionless.Core.Models;
6+
7+
/// <summary>
8+
/// A saved view captures filter, time range, and display settings for a dashboard page.
9+
/// Org-scoped; optionally user-private when UserId is set.
10+
/// </summary>
11+
public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates
12+
{
13+
/// <summary>The set of valid dashboard view identifiers.</summary>
14+
public static readonly string[] ValidViews = ["events", "issues", "stream"];
15+
16+
/// <summary>Valid column IDs per view, matching the TanStack Table column definitions.</summary>
17+
public static readonly IReadOnlyDictionary<string, IReadOnlySet<string>> ValidColumnIds =
18+
new Dictionary<string, IReadOnlySet<string>>
19+
{
20+
["events"] = new HashSet<string> { "user", "date" },
21+
["issues"] = new HashSet<string> { "status", "users", "events", "first", "last" },
22+
["stream"] = new HashSet<string> { "user", "date" }
23+
};
24+
25+
/// <summary>Union of all valid column IDs across all views.</summary>
26+
public static readonly IReadOnlySet<string> AllValidColumnIds =
27+
new HashSet<string>(ValidColumnIds.Values.SelectMany(ids => ids));
28+
29+
// Identity
30+
[ObjectId]
31+
public string Id { get; set; } = null!;
32+
33+
[ObjectId]
34+
[Required]
35+
public string OrganizationId { get; set; } = null!;
36+
37+
// User associations
38+
/// <summary>When set, this view is private to the specified user. Null means org-wide.</summary>
39+
[ObjectId]
40+
public string? UserId { get; set; }
41+
42+
/// <summary>The user who originally created this view.</summary>
43+
[ObjectId]
44+
[Required]
45+
public string CreatedByUserId { get; set; } = null!;
46+
47+
/// <summary>The user who last modified this view.</summary>
48+
[ObjectId]
49+
public string? UpdatedByUserId { get; set; }
50+
51+
// View configuration
52+
/// <summary>Raw Lucene filter query string, e.g. "(status:open OR status:regressed)". Null means no filter (show all).</summary>
53+
[MaxLength(2000)]
54+
public string? Filter { get; set; }
55+
56+
/// <summary>JSON array of structured filter objects for UI chip hydration.</summary>
57+
[MaxLength(10000)]
58+
public string? FilterDefinitions { get; set; }
59+
60+
/// <summary>Column visibility state per dashboard table, keyed by column id.</summary>
61+
public Dictionary<string, bool>? Columns { get; set; }
62+
63+
/// <summary>Whether this view loads automatically when navigating to the page.</summary>
64+
public bool IsDefault { get; set; }
65+
66+
/// <summary>Display name shown in the sidebar and picker.</summary>
67+
[Required]
68+
[MaxLength(100)]
69+
public string Name { get; set; } = null!;
70+
71+
/// <summary>Date-math time range, e.g. "[now-7d TO now]". Null if no time constraint.</summary>
72+
[MaxLength(100)]
73+
public string? Time { get; set; }
74+
75+
/// <summary>Schema version for future filter definition migrations.</summary>
76+
public int Version { get; set; } = 1;
77+
78+
/// <summary>Dashboard page identifier: "events", "issues", or "stream".</summary>
79+
[Required]
80+
[RegularExpression("^(events|issues|stream)$")]
81+
public string View { get; set; } = null!;
82+
83+
// Timestamps
84+
public DateTime CreatedUtc { get; set; }
85+
public DateTime UpdatedUtc { get; set; }
86+
}

src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ ILoggerFactory loggerFactory
4444
AddIndex(Migrations = new MigrationIndex(this, _appOptions.ElasticsearchOptions.ScopePrefix + "migrations", appOptions.ElasticsearchOptions.NumberOfReplicas));
4545
AddIndex(Organizations = new OrganizationIndex(this));
4646
AddIndex(Projects = new ProjectIndex(this));
47+
AddIndex(SavedViews = new SavedViewIndex(this));
4748
AddIndex(Tokens = new TokenIndex(this));
4849
AddIndex(Users = new UserIndex(this));
4950
AddIndex(WebHooks = new WebHookIndex(this));
@@ -71,6 +72,7 @@ public override void ConfigureGlobalQueryBuilders(ElasticQueryBuilder builder)
7172
public MigrationIndex Migrations { get; }
7273
public OrganizationIndex Organizations { get; }
7374
public ProjectIndex Projects { get; }
75+
public SavedViewIndex SavedViews { get; }
7476
public TokenIndex Tokens { get; }
7577
public UserIndex Users { get; }
7678
public WebHookIndex WebHooks { get; }

src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public override TypeMappingDescriptor<Organization> ConfigureIndexMapping(TypeMa
2424
.Text(f => f.Name(e => e.Name).AddKeywordField())
2525
.Keyword(f => f.Name(u => u.StripeCustomerId))
2626
.Boolean(f => f.Name(u => u.HasPremiumFeatures))
27+
.Keyword(f => f.Name(u => u.Features))
2728
.Keyword(f => f.Name(u => u.PlanId))
2829
.Keyword(f => f.Name(u => u.PlanName).IgnoreAbove(256))
2930
.Date(f => f.Name(u => u.SubscribeDate))
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Foundatio.Repositories.Elasticsearch.Configuration;
2+
using Foundatio.Repositories.Elasticsearch.Extensions;
3+
using Nest;
4+
5+
namespace Exceptionless.Core.Repositories.Configuration;
6+
7+
public sealed class SavedViewIndex : VersionedIndex<Models.SavedView>
8+
{
9+
internal const string KEYWORD_LOWERCASE_ANALYZER = "keyword_lowercase";
10+
11+
private readonly ExceptionlessElasticConfiguration _configuration;
12+
13+
public SavedViewIndex(ExceptionlessElasticConfiguration configuration) : base(configuration, configuration.Options.ScopePrefix + "saved-views", 1)
14+
{
15+
_configuration = configuration;
16+
}
17+
18+
public override TypeMappingDescriptor<Models.SavedView> ConfigureIndexMapping(TypeMappingDescriptor<Models.SavedView> map)
19+
{
20+
return map
21+
.Dynamic(false)
22+
.Properties(p => p
23+
.SetupDefaults()
24+
.Keyword(f => f.Name(e => e.OrganizationId))
25+
.Keyword(f => f.Name(e => e.UserId))
26+
.Keyword(f => f.Name(e => e.CreatedByUserId))
27+
.Keyword(f => f.Name(e => e.UpdatedByUserId))
28+
.Text(f => f.Name(e => e.Name).Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField())
29+
.Keyword(f => f.Name(e => e.View))
30+
.Boolean(f => f.Name(e => e.IsDefault))
31+
.Number(f => f.Name(e => e.Version).Type(NumberType.Integer)));
32+
}
33+
34+
public override CreateIndexDescriptor ConfigureIndex(CreateIndexDescriptor idx)
35+
{
36+
return base.ConfigureIndex(idx.Settings(s => s
37+
.Analysis(d => d.Analyzers(b => b.Custom(KEYWORD_LOWERCASE_ANALYZER, c => c.Filters("lowercase").Tokenizer("keyword"))))
38+
.NumberOfShards(_configuration.Options.NumberOfShards)
39+
.NumberOfReplicas(_configuration.Options.NumberOfReplicas)
40+
.Priority(5)));
41+
}
42+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using Exceptionless.Core.Models;
2+
using Foundatio.Repositories;
3+
using Foundatio.Repositories.Models;
4+
5+
namespace Exceptionless.Core.Repositories;
6+
7+
public interface ISavedViewRepository : IRepositoryOwnedByOrganization<SavedView>
8+
{
9+
Task<FindResults<SavedView>> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor<SavedView>? options = null);
10+
Task<FindResults<SavedView>> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor<SavedView>? options = null);
11+
Task<FindResults<SavedView>> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor<SavedView>? options = null);
12+
Task<long> CountByOrganizationIdAsync(string organizationId);
13+
14+
/// <summary>Removes all private saved views belonging to a specific user within an organization.</summary>
15+
Task<long> RemovePrivateByUserIdAsync(string organizationId, string userId);
16+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
using Exceptionless.Core.Models;
2+
using Exceptionless.Core.Repositories.Configuration;
3+
using Foundatio.Repositories;
4+
using Foundatio.Repositories.Models;
5+
using Nest;
6+
7+
namespace Exceptionless.Core.Repositories;
8+
9+
public class SavedViewRepository : RepositoryOwnedByOrganization<SavedView>, ISavedViewRepository
10+
{
11+
public SavedViewRepository(ExceptionlessElasticConfiguration configuration, AppOptions options)
12+
: base(configuration.SavedViews, null!, options)
13+
{
14+
}
15+
16+
public Task<FindResults<SavedView>> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor<SavedView>? options = null)
17+
{
18+
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
19+
&& Query<SavedView>.Term(e => e.View, view);
20+
21+
return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options);
22+
}
23+
24+
public Task<FindResults<SavedView>> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor<SavedView>? options = null)
25+
{
26+
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
27+
&& Query<SavedView>.Term(e => e.View, view)
28+
&& (!Query<SavedView>.Exists(e => e.Field(f => f.UserId))
29+
|| Query<SavedView>.Term(e => e.UserId, userId));
30+
31+
return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options);
32+
}
33+
34+
public Task<FindResults<SavedView>> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor<SavedView>? options = null)
35+
{
36+
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
37+
&& (!Query<SavedView>.Exists(e => e.Field(f => f.UserId))
38+
|| Query<SavedView>.Term(e => e.UserId, userId));
39+
40+
return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options);
41+
}
42+
43+
public async Task<long> RemovePrivateByUserIdAsync(string organizationId, string userId)
44+
{
45+
var filter = Query<SavedView>.Term(e => e.OrganizationId, organizationId)
46+
&& Query<SavedView>.Term(e => e.UserId, userId);
47+
48+
var results = await FindAsync(q => q.ElasticFilter(filter), o => o.PageLimit(1000));
49+
if (results.Total == 0)
50+
return 0;
51+
52+
await RemoveAsync(results.Documents);
53+
return results.Total;
54+
}
55+
56+
public async Task<long> CountByOrganizationIdAsync(string organizationId)
57+
{
58+
return await CountAsync(q => q.Organization(organizationId));
59+
}
60+
}

0 commit comments

Comments
 (0)