From 67884ad7c9587dc97377c29c869ac75a211dcd56 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Thu, 26 Feb 2026 22:53:35 -0600 Subject: [PATCH] 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. --- .agents/skills/frontend-architecture/SKILL.md | 6 + .../skills/typescript-conventions/SKILL.md | 9 + src/Exceptionless.Core/Bootstrapper.cs | 1 + src/Exceptionless.Core/Models/Organization.cs | 15 + src/Exceptionless.Core/Models/SavedView.cs | 86 + .../ExceptionlessElasticConfiguration.cs | 2 + .../Indexes/OrganizationIndex.cs | 1 + .../Configuration/Indexes/SavedViewIndex.cs | 42 + .../Interfaces/ISavedViewRepository.cs | 16 + .../Repositories/SavedViewRepository.cs | 60 + .../Services/OrganizationService.cs | 18 +- src/Exceptionless.Web/ClientApp/package.json | 2 +- .../components/filters/helpers.svelte.test.ts | 447 ++++- .../components/filters/helpers.svelte.ts | 87 +- .../lib/features/organizations/api.svelte.ts | 45 + .../lib/features/saved-views/api.svelte.ts | 120 ++ .../components/saved-view-picker.svelte | 615 +++++++ .../src/lib/features/saved-views/index.ts | 3 + .../src/lib/features/saved-views/models.ts | 13 + .../saved-views/use-saved-views.svelte.ts | 248 +++ .../ClientApp/src/lib/generated/api.ts | 56 +- .../ClientApp/src/lib/generated/schemas.ts | 88 +- .../(app)/(components)/layouts/sidebar.svelte | 72 +- .../ClientApp/src/routes/(app)/+layout.svelte | 92 +- .../ClientApp/src/routes/(app)/+page.svelte | 31 +- .../src/routes/(app)/issues/+page.svelte | 31 +- .../[organizationId]/features/+page.svelte | 116 ++ .../[organizationId]/routes.svelte.ts | 8 + .../src/routes/(app)/stream/+page.svelte | 35 +- .../ClientApp/src/routes/routes.svelte.ts | 9 + .../Controllers/OrganizationController.cs | 47 + .../Controllers/SavedViewController.cs | 317 ++++ src/Exceptionless.Web/Mapping/ApiMapper.cs | 12 + .../Mapping/SavedViewMapper.cs | 19 + .../Models/Organization/ViewOrganization.cs | 1 + .../Models/SavedView/NewSavedView.cs | 93 + .../Models/SavedView/UpdateSavedView.cs | 26 + .../Models/SavedView/ViewSavedView.cs | 34 + .../Controllers/Data/openapi.json | 576 +++++++ .../OrganizationControllerTests.cs | 124 ++ .../Controllers/SavedViewControllerTests.cs | 1524 +++++++++++++++++ .../Mapping/SavedViewMapperTests.cs | 198 +++ tests/http/saved-views.http | 93 + 43 files changed, 5408 insertions(+), 30 deletions(-) create mode 100644 src/Exceptionless.Core/Models/SavedView.cs create mode 100644 src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs create mode 100644 src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs create mode 100644 src/Exceptionless.Core/Repositories/SavedViewRepository.cs create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/features/+page.svelte create mode 100644 src/Exceptionless.Web/Controllers/SavedViewController.cs create mode 100644 src/Exceptionless.Web/Mapping/SavedViewMapper.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/NewSavedView.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs create mode 100644 src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs create mode 100644 tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs create mode 100644 tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs create mode 100644 tests/http/saved-views.http diff --git a/.agents/skills/frontend-architecture/SKILL.md b/.agents/skills/frontend-architecture/SKILL.md index f28b12c4a5..1504fac612 100644 --- a/.agents/skills/frontend-architecture/SKILL.md +++ b/.agents/skills/frontend-architecture/SKILL.md @@ -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: diff --git a/.agents/skills/typescript-conventions/SKILL.md b/.agents/skills/typescript-conventions/SKILL.md index d4a35f0a2a..a54a1e5c5c 100644 --- a/.agents/skills/typescript-conventions/SKILL.md +++ b/.agents/skills/typescript-conventions/SKILL.md @@ -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 @@ -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 diff --git a/src/Exceptionless.Core/Bootstrapper.cs b/src/Exceptionless.Core/Bootstrapper.cs index f264439dca..f90f3a5559 100644 --- a/src/Exceptionless.Core/Bootstrapper.cs +++ b/src/Exceptionless.Core/Bootstrapper.cs @@ -153,6 +153,7 @@ public static void RegisterServices(IServiceCollection services, AppOptions appO services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Exceptionless.Core/Models/Organization.cs b/src/Exceptionless.Core/Models/Organization.cs index 0c09db2721..d1c75df1b8 100644 --- a/src/Exceptionless.Core/Models/Organization.cs +++ b/src/Exceptionless.Core/Models/Organization.cs @@ -130,6 +130,12 @@ public Organization() /// public bool HasPremiumFeatures { get; set; } + /// + /// Set of enabled feature flags for this organization (e.g., "feature-saved-views"). + /// Feature identifiers are always stored in lowercase. + /// + public ISet Features { get; set; } = new HashSet(); + /// /// Maximum number of users allowed by the current plan. /// @@ -176,3 +182,12 @@ public enum BillingStatus Canceled = 3, Unpaid = 4 } + +/// +/// Well-known organization feature flag identifiers. +/// +public static class OrganizationFeatures +{ + /// Enables the Saved Views feature for the organization. + public const string SavedViews = "feature-saved-views"; +} diff --git a/src/Exceptionless.Core/Models/SavedView.cs b/src/Exceptionless.Core/Models/SavedView.cs new file mode 100644 index 0000000000..a394210c96 --- /dev/null +++ b/src/Exceptionless.Core/Models/SavedView.cs @@ -0,0 +1,86 @@ +using System.ComponentModel.DataAnnotations; +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Models; + +/// +/// A saved view captures filter, time range, and display settings for a dashboard page. +/// Org-scoped; optionally user-private when UserId is set. +/// +public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates +{ + /// The set of valid dashboard view identifiers. + public static readonly string[] ValidViews = ["events", "issues", "stream"]; + + /// Valid column IDs per view, matching the TanStack Table column definitions. + public static readonly IReadOnlyDictionary> ValidColumnIds = + new Dictionary> + { + ["events"] = new HashSet { "user", "date" }, + ["issues"] = new HashSet { "status", "users", "events", "first", "last" }, + ["stream"] = new HashSet { "user", "date" } + }; + + /// Union of all valid column IDs across all views. + public static readonly IReadOnlySet AllValidColumnIds = + new HashSet(ValidColumnIds.Values.SelectMany(ids => ids)); + + // Identity + [ObjectId] + public string Id { get; set; } = null!; + + [ObjectId] + [Required] + public string OrganizationId { get; set; } = null!; + + // User associations + /// When set, this view is private to the specified user. Null means org-wide. + [ObjectId] + public string? UserId { get; set; } + + /// The user who originally created this view. + [ObjectId] + [Required] + public string CreatedByUserId { get; set; } = null!; + + /// The user who last modified this view. + [ObjectId] + public string? UpdatedByUserId { get; set; } + + // View configuration + /// Raw Lucene filter query string, e.g. "(status:open OR status:regressed)". Null means no filter (show all). + [MaxLength(2000)] + public string? Filter { get; set; } + + /// JSON array of structured filter objects for UI chip hydration. + [MaxLength(10000)] + public string? FilterDefinitions { get; set; } + + /// Column visibility state per dashboard table, keyed by column id. + public Dictionary? Columns { get; set; } + + /// Whether this view loads automatically when navigating to the page. + public bool IsDefault { get; set; } + + /// Display name shown in the sidebar and picker. + [Required] + [MaxLength(100)] + public string Name { get; set; } = null!; + + /// Date-math time range, e.g. "[now-7d TO now]". Null if no time constraint. + [MaxLength(100)] + public string? Time { get; set; } + + /// Schema version for future filter definition migrations. + public int Version { get; set; } = 1; + + /// Dashboard page identifier: "events", "issues", or "stream". + [Required] + [RegularExpression("^(events|issues|stream)$")] + public string View { get; set; } = null!; + + // Timestamps + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs index 4641056b31..2158adeade 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/ExceptionlessElasticConfiguration.cs @@ -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)); @@ -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; } diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs index 079a916f2e..11a487b319 100644 --- a/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/OrganizationIndex.cs @@ -24,6 +24,7 @@ public override TypeMappingDescriptor 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)) diff --git a/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs new file mode 100644 index 0000000000..96b947aa2a --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Configuration/Indexes/SavedViewIndex.cs @@ -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 +{ + 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 ConfigureIndexMapping(TypeMappingDescriptor 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))); + } +} diff --git a/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs b/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs new file mode 100644 index 0000000000..eceda6860d --- /dev/null +++ b/src/Exceptionless.Core/Repositories/Interfaces/ISavedViewRepository.cs @@ -0,0 +1,16 @@ +using Exceptionless.Core.Models; +using Foundatio.Repositories; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Core.Repositories; + +public interface ISavedViewRepository : IRepositoryOwnedByOrganization +{ + Task> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor? options = null); + Task> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor? options = null); + Task> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null); + Task CountByOrganizationIdAsync(string organizationId); + + /// Removes all private saved views belonging to a specific user within an organization. + Task RemovePrivateByUserIdAsync(string organizationId, string userId); +} diff --git a/src/Exceptionless.Core/Repositories/SavedViewRepository.cs b/src/Exceptionless.Core/Repositories/SavedViewRepository.cs new file mode 100644 index 0000000000..36189d2324 --- /dev/null +++ b/src/Exceptionless.Core/Repositories/SavedViewRepository.cs @@ -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, ISavedViewRepository +{ + public SavedViewRepository(ExceptionlessElasticConfiguration configuration, AppOptions options) + : base(configuration.SavedViews, null!, options) + { + } + + public Task> GetByViewAsync(string organizationId, string view, CommandOptionsDescriptor? options = null) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && Query.Term(e => e.View, view); + + return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options); + } + + public Task> GetByViewForUserAsync(string organizationId, string view, string userId, CommandOptionsDescriptor? options = null) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && Query.Term(e => e.View, view) + && (!Query.Exists(e => e.Field(f => f.UserId)) + || Query.Term(e => e.UserId, userId)); + + return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options); + } + + public Task> GetByOrganizationForUserAsync(string organizationId, string userId, CommandOptionsDescriptor? options = null) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && (!Query.Exists(e => e.Field(f => f.UserId)) + || Query.Term(e => e.UserId, userId)); + + return FindAsync(q => q.ElasticFilter(filter).SortAscending(e => e.Name.Suffix("keyword")), options); + } + + public async Task RemovePrivateByUserIdAsync(string organizationId, string userId) + { + var filter = Query.Term(e => e.OrganizationId, organizationId) + && Query.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 CountByOrganizationIdAsync(string organizationId) + { + return await CountAsync(q => q.Organization(organizationId)); + } +} diff --git a/src/Exceptionless.Core/Services/OrganizationService.cs b/src/Exceptionless.Core/Services/OrganizationService.cs index c5acf91701..ebd041b1e5 100644 --- a/src/Exceptionless.Core/Services/OrganizationService.cs +++ b/src/Exceptionless.Core/Services/OrganizationService.cs @@ -13,6 +13,7 @@ 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; @@ -20,10 +21,11 @@ public class OrganizationService : IStartupAction 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; @@ -180,6 +182,19 @@ public Task RemoveWebHooksAsync(Organization organization) return _webHookRepository.RemoveAllByOrganizationIdAsync(organization.Id); } + public Task RemoveSavedViewsAsync(Organization organization) + { + _logger.LogInformation("Removing saved views for {OrganizationName} ({OrganizationId})", organization.Name, organization.Id); + return _savedViewRepository.RemoveAllByOrganizationIdAsync(organization.Id); + } + + /// Removes all private saved views for a user leaving an organization. Org-wide views created by that user are preserved. + public Task 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) @@ -187,6 +202,7 @@ public async Task SoftDeleteOrganizationAsync(Organization organization, string await RemoveTokensAsync(organization); await RemoveWebHooksAsync(organization); + await RemoveSavedViewsAsync(organization); await CancelSubscriptionsAsync(organization); await RemoveUsersAsync(organization, currentUserId); await CleanupProjectNotificationSettingsAsync(organization, []); diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 1d2d918657..df127fa10c 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -100,4 +100,4 @@ "overrides": { "storybook": "$storybook" } -} \ No newline at end of file +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts index c5907c7574..1ac9668db7 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.test.ts @@ -1,6 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { quoteIfSpecialCharacters } from './helpers.svelte'; +import { deserializeFilters, quoteIfSpecialCharacters, serializeFilters } from './helpers.svelte'; +import { + BooleanFilter, + DateFilter, + KeywordFilter, + LevelFilter, + NumberFilter, + ProjectFilter, + ReferenceFilter, + SessionFilter, + StatusFilter, + StringFilter, + TagFilter, + TypeFilter, + VersionFilter +} from './models.svelte'; describe('helpers.svelte', () => { it('quoteIfSpecialCharacters handles tabs and newlines', () => { @@ -40,9 +55,439 @@ describe('helpers.svelte', () => { it('quoteIfSpecialCharacters quotes all Lucene special characters', () => { const luceneSpecials = ['+', '-', '&&', '||', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\', '/']; + for (const char of luceneSpecials) { expect(quoteIfSpecialCharacters(char)).toBe(`"${char}"`); expect(quoteIfSpecialCharacters(`foo${char}bar`)).toBe(`"foo${char}bar"`); } }); }); + +describe('serializeFilters', () => { + it('serializes an empty array', () => { + expect(serializeFilters([])).toBe('[]'); + }); + + it('serializes a BooleanFilter with term and value', () => { + const filters = [new BooleanFilter('is_fixed', true)]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ term: 'is_fixed', type: 'boolean', value: true }); + }); + + it('serializes a DateFilter with term and string value', () => { + const filters = [new DateFilter('date', '2024-01-01')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'date', type: 'date', value: '2024-01-01' }); + }); + + it('serializes a KeywordFilter with value', () => { + const filters = [new KeywordFilter('status:open')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'keyword', value: 'status:open' }); + }); + + it('serializes a LevelFilter with multiple values', () => { + const filters = [new LevelFilter(['Error', 'Fatal'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'level', value: ['Error', 'Fatal'] }); + }); + + it('serializes a NumberFilter with term and value', () => { + const filters = [new NumberFilter('value', 42)]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'value', type: 'number', value: 42 }); + }); + + it('serializes a ProjectFilter with multiple values', () => { + const filters = [new ProjectFilter(['proj1', 'proj2'])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'project', value: ['proj1', 'proj2'] }); + }); + + it('serializes a ReferenceFilter with value', () => { + const filters = [new ReferenceFilter('ref-123')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'reference', value: 'ref-123' }); + }); + + it('serializes a SessionFilter with value', () => { + const filters = [new SessionFilter('session-abc')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'session', value: 'session-abc' }); + }); + + it('serializes a StatusFilter with multiple values', () => { + const filters = [new StatusFilter(['open', 'regressed'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'status', value: ['open', 'regressed'] }); + }); + + it('serializes a StringFilter with term and value', () => { + const filters = [new StringFilter('error.message', 'null ref')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'error.message', type: 'string', value: 'null ref' }); + }); + + it('serializes a TagFilter with values', () => { + const filters = [new TagFilter(['error', 'log'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'tag', value: ['error', 'log'] }); + }); + + it('serializes a TypeFilter with values', () => { + const filters = [new TypeFilter(['error', 'log'] as never[])]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'type', value: ['error', 'log'] }); + }); + + it('serializes a VersionFilter with term and value', () => { + const filters = [new VersionFilter('version', '1.2.3')]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ term: 'version', type: 'version', value: '1.2.3' }); + }); + + it('serializes filters without optional term or value', () => { + const filters = [new BooleanFilter()]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result[0]).toEqual({ type: 'boolean' }); + }); + + it('serializes multiple filters', () => { + const filters = [new KeywordFilter('error'), new StatusFilter(['open'] as never[]), new BooleanFilter('is_fixed', false)]; + const result = JSON.parse(serializeFilters(filters)); + + expect(result).toHaveLength(3); + expect(result[0].type).toBe('keyword'); + expect(result[1].type).toBe('status'); + expect(result[2].type).toBe('boolean'); + }); +}); + +describe('deserializeFilters', () => { + it('deserializes an empty array', () => { + expect(deserializeFilters('[]')).toEqual([]); + }); + + it('deserializes a BooleanFilter', () => { + const filters = deserializeFilters('[{"type":"boolean","term":"is_fixed","value":true}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(BooleanFilter); + expect((filters[0] as BooleanFilter).term).toBe('is_fixed'); + expect((filters[0] as BooleanFilter).value).toBe(true); + }); + + it('deserializes a DateFilter', () => { + const filters = deserializeFilters('[{"type":"date","term":"date","value":"2024-01-01"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(DateFilter); + expect((filters[0] as DateFilter).term).toBe('date'); + expect((filters[0] as DateFilter).value).toBe('2024-01-01'); + }); + + it('deserializes a KeywordFilter', () => { + const filters = deserializeFilters('[{"type":"keyword","value":"status:open"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + expect((filters[0] as KeywordFilter).value).toBe('status:open'); + }); + + it('deserializes a LevelFilter', () => { + const filters = deserializeFilters('[{"type":"level","value":["Error","Fatal"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(LevelFilter); + expect((filters[0] as LevelFilter).value).toEqual(['Error', 'Fatal']); + }); + + it('deserializes a NumberFilter', () => { + const filters = deserializeFilters('[{"type":"number","term":"value","value":42}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(NumberFilter); + expect((filters[0] as NumberFilter).term).toBe('value'); + expect((filters[0] as NumberFilter).value).toBe(42); + }); + + it('deserializes a ProjectFilter', () => { + const filters = deserializeFilters('[{"type":"project","value":["p1","p2"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(ProjectFilter); + expect((filters[0] as ProjectFilter).value).toEqual(['p1', 'p2']); + }); + + it('deserializes a ReferenceFilter', () => { + const filters = deserializeFilters('[{"type":"reference","value":"ref-123"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(ReferenceFilter); + expect((filters[0] as ReferenceFilter).value).toBe('ref-123'); + }); + + it('deserializes a SessionFilter', () => { + const filters = deserializeFilters('[{"type":"session","value":"session-abc"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(SessionFilter); + expect((filters[0] as SessionFilter).value).toBe('session-abc'); + }); + + it('deserializes a StatusFilter', () => { + const filters = deserializeFilters('[{"type":"status","value":["open","regressed"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(StatusFilter); + expect((filters[0] as StatusFilter).value).toEqual(['open', 'regressed']); + }); + + it('deserializes a StringFilter', () => { + const filters = deserializeFilters('[{"type":"string","term":"error.message","value":"null ref"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(StringFilter); + expect((filters[0] as StringFilter).term).toBe('error.message'); + expect((filters[0] as StringFilter).value).toBe('null ref'); + }); + + it('deserializes a TagFilter', () => { + const filters = deserializeFilters('[{"type":"tag","value":["error","log"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(TagFilter); + expect((filters[0] as TagFilter).value).toEqual(['error', 'log']); + }); + + it('deserializes a TypeFilter', () => { + const filters = deserializeFilters('[{"type":"type","value":["error","log"]}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(TypeFilter); + expect((filters[0] as TypeFilter).value).toEqual(['error', 'log']); + }); + + it('deserializes a VersionFilter', () => { + const filters = deserializeFilters('[{"type":"version","term":"version","value":"1.2.3"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(VersionFilter); + expect((filters[0] as VersionFilter).term).toBe('version'); + expect((filters[0] as VersionFilter).value).toBe('1.2.3'); + }); + + it('skips unknown filter types', () => { + const filters = deserializeFilters('[{"type":"unknown","value":"test"},{"type":"keyword","value":"valid"}]'); + + expect(filters).toHaveLength(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + }); +}); + +describe('round-trip serialization', () => { + it('round-trips a BooleanFilter', () => { + const original = [new BooleanFilter('is_fixed', true)]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(BooleanFilter); + expect((result[0] as BooleanFilter).term).toBe('is_fixed'); + expect((result[0] as BooleanFilter).value).toBe(true); + }); + + it('round-trips a DateFilter with string value', () => { + const original = [new DateFilter('created_utc', '2024-06-15T00:00:00Z')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as DateFilter).term).toBe('created_utc'); + expect((result[0] as DateFilter).value).toBe('2024-06-15T00:00:00Z'); + }); + + it('round-trips a KeywordFilter', () => { + const original = [new KeywordFilter('status:open OR status:regressed')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as KeywordFilter).value).toBe('status:open OR status:regressed'); + }); + + it('round-trips a LevelFilter with multiple levels', () => { + const original = [new LevelFilter(['Error', 'Warning', 'Fatal'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as LevelFilter).value).toEqual(['Error', 'Warning', 'Fatal']); + }); + + it('round-trips a NumberFilter', () => { + const original = [new NumberFilter('count', 99)]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as NumberFilter).term).toBe('count'); + expect((result[0] as NumberFilter).value).toBe(99); + }); + + it('round-trips a ProjectFilter', () => { + const original = [new ProjectFilter(['abc123', 'def456'])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as ProjectFilter).value).toEqual(['abc123', 'def456']); + }); + + it('round-trips a ReferenceFilter', () => { + const original = [new ReferenceFilter('ref-xyz')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as ReferenceFilter).value).toBe('ref-xyz'); + }); + + it('round-trips a SessionFilter', () => { + const original = [new SessionFilter('sess-001')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as SessionFilter).value).toBe('sess-001'); + }); + + it('round-trips a StatusFilter', () => { + const original = [new StatusFilter(['open', 'regressed'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as StatusFilter).value).toEqual(['open', 'regressed']); + }); + + it('round-trips a StringFilter', () => { + const original = [new StringFilter('error.type', 'NullReferenceException')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as StringFilter).term).toBe('error.type'); + expect((result[0] as StringFilter).value).toBe('NullReferenceException'); + }); + + it('round-trips a TagFilter', () => { + const original = [new TagFilter(['Critical', 'UI'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as TagFilter).value).toEqual(['Critical', 'UI']); + }); + + it('round-trips a TypeFilter', () => { + const original = [new TypeFilter(['error', 'session'] as never[])]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as TypeFilter).value).toEqual(['error', 'session']); + }); + + it('round-trips a VersionFilter', () => { + const original = [new VersionFilter('version', '2.0.0-beta')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(1); + expect((result[0] as VersionFilter).term).toBe('version'); + expect((result[0] as VersionFilter).value).toBe('2.0.0-beta'); + }); + + it('round-trips a complex mix of filters', () => { + const original = [ + new KeywordFilter('error'), + new StatusFilter(['open'] as never[]), + new BooleanFilter('is_fixed', false), + new NumberFilter('value', 10), + new StringFilter('error.message', 'Connection timeout'), + new ProjectFilter(['proj-a']), + new VersionFilter('version', '3.1.0') + ]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(7); + expect(result[0]).toBeInstanceOf(KeywordFilter); + expect(result[1]).toBeInstanceOf(StatusFilter); + expect(result[2]).toBeInstanceOf(BooleanFilter); + expect(result[3]).toBeInstanceOf(NumberFilter); + expect(result[4]).toBeInstanceOf(StringFilter); + expect(result[5]).toBeInstanceOf(ProjectFilter); + expect(result[6]).toBeInstanceOf(VersionFilter); + }); + + it('round-trips filters with undefined values', () => { + const original = [new BooleanFilter('is_fixed'), new StringFilter('term')]; + const result = deserializeFilters(serializeFilters(original)); + + expect(result).toHaveLength(2); + expect((result[0] as BooleanFilter).term).toBe('is_fixed'); + expect((result[0] as BooleanFilter).value).toBeUndefined(); + expect((result[1] as StringFilter).term).toBe('term'); + expect((result[1] as StringFilter).value).toBeUndefined(); + }); +}); + +describe('defensive deserialization', () => { + it('returns empty array for invalid JSON', () => { + expect(deserializeFilters('not json')).toEqual([]); + }); + + it('returns empty array for null input', () => { + expect(deserializeFilters(null as unknown as string)).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + expect(deserializeFilters(undefined as unknown as string)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(deserializeFilters('')).toEqual([]); + }); + + it('returns empty array for JSON object (not array)', () => { + expect(deserializeFilters('{"type":"keyword","value":"test"}')).toEqual([]); + }); + + it('returns empty array for JSON number', () => { + expect(deserializeFilters('42')).toEqual([]); + }); + + it('handles missing type field gracefully', () => { + const result = deserializeFilters('[{"value":"test"}]'); + + expect(result).toEqual([]); + }); + + it('handles missing value field gracefully', () => { + const result = deserializeFilters('[{"type":"keyword"}]'); + + expect(result).toHaveLength(1); + expect(result[0]).toBeInstanceOf(KeywordFilter); + }); + + it('handles XSS payload in value without crashing', () => { + const xss = ''; + const result = deserializeFilters(JSON.stringify([{ type: 'keyword', value: xss }])); + + expect(result).toHaveLength(1); + expect((result[0] as KeywordFilter).value).toBe(xss); + }); +}); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts index 4fcf26a9c2..b3677c3e40 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/helpers.svelte.ts @@ -3,7 +3,21 @@ import type { IFilter } from '$comp/faceted-filter'; import { organization } from '$features/organizations/context.svelte'; import { SvelteMap } from 'svelte/reactivity'; -import { DateFilter, KeywordFilter, type ProjectFilter, type StringFilter } from './models.svelte'; +import { + BooleanFilter, + DateFilter, + KeywordFilter, + LevelFilter, + NumberFilter, + ProjectFilter, + ReferenceFilter, + SessionFilter, + StatusFilter, + StringFilter, + TagFilter, + TypeFilter, + VersionFilter +} from './models.svelte'; let filterCacheVersion = $state(1); export function filterCacheVersionNumber() { @@ -11,6 +25,12 @@ export function filterCacheVersionNumber() { } const filterCache = new SvelteMap(); +interface SerializedFilter { + term?: string; + type: string; + value?: unknown; +} + export function applyTimeFilter(filters: IFilter[], time: null | string): IFilter[] { const dateFilterIndex = filters.findIndex((f) => f.key === 'date-date'); if (dateFilterIndex >= 0) { @@ -34,6 +54,20 @@ export function clearFilterCache() { filterCacheVersion = 1; } +export function deserializeFilters(json: string): IFilter[] { + try { + const parsed: SerializedFilter[] = JSON.parse(json); + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.map(reconstructFilter).filter((f): f is IFilter => f !== null); + } catch { + return []; + } +} + export function filterChanged(filters: IFilter[], addedOrUpdated: IFilter): IFilter[] { const index = filters.findIndex((f) => f.id === addedOrUpdated.id); if (index === -1) { @@ -105,6 +139,24 @@ export function quoteIfSpecialCharacters(value?: null | string): null | string | return trimmed; } +export function serializeFilters(filters: IFilter[]): string { + const serialized: SerializedFilter[] = filters.map((filter) => { + const entry: SerializedFilter = { type: filter.type }; + + if ('term' in filter && (filter as { term?: string }).term !== undefined) { + entry.term = (filter as { term?: string }).term; + } + + if ('value' in filter) { + entry.value = (filter as { value?: unknown }).value; + } + + return entry; + }); + + return JSON.stringify(serialized); +} + export function shouldRefreshPersistentEventChanged( filters: IFilter[], filter: null | string, @@ -195,3 +247,36 @@ function processFilterRules(filters: IFilter[]): IFilter[] { return Array.from(uniqueFilters.values()); } + +function reconstructFilter(data: SerializedFilter): IFilter | null { + switch (data.type) { + case 'boolean': + return new BooleanFilter(data.term, data.value as boolean | undefined); + case 'date': + return new DateFilter(data.term, data.value as Date | string | undefined); + case 'keyword': + return new KeywordFilter(data.value as string | undefined); + case 'level': + return new LevelFilter(data.value as [] | undefined); + case 'number': + return new NumberFilter(data.term, data.value as number | undefined); + case 'project': + return new ProjectFilter(data.value as string[] | undefined); + case 'reference': + return new ReferenceFilter(data.value as string | undefined); + case 'session': + return new SessionFilter(data.value as string | undefined); + case 'status': + return new StatusFilter(data.value as [] | undefined); + case 'string': + return new StringFilter(data.term, data.value as string | undefined); + case 'tag': + return new TagFilter(data.value as [] | undefined); + case 'type': + return new TypeFilter(data.value as [] | undefined); + case 'version': + return new VersionFilter(data.term, data.value as string | undefined); + default: + return null; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts index b3be2e34c2..b99f5ea2fc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/api.svelte.ts @@ -30,6 +30,7 @@ export const queryKeys = { list: (mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, 'list', { mode }] as const) : ([...queryKeys.type, 'list'] as const)), postOrganization: () => [...queryKeys.type, 'post-organization'] as const, setBonusOrganization: (id: string | undefined) => [...queryKeys.type, id, 'set-bonus'] as const, + setFeature: (id: string | undefined) => [...queryKeys.type, id, 'set-feature'] as const, suspendOrganization: (id: string | undefined) => [...queryKeys.type, id, 'suspend'] as const, type: ['Organization'] as const, unsuspendOrganization: (id: string | undefined) => [...queryKeys.type, id, 'unsuspend'] as const @@ -133,6 +134,12 @@ export interface PostSuspendOrganizationRequest { }; } +export interface SetOrganizationFeatureRequest { + route: { + id: string | undefined; + }; +} + export function addOrganizationUser(request: AddOrganizationUserRequest) { const queryClient = useQueryClient(); return createMutation<{ emailAddress: string }, ProblemDetails, string>(() => ({ @@ -414,3 +421,41 @@ export function postSuspendOrganization(request: PostSuspendOrganizationRequest) } })); } + +export function removeOrganizationFeature(request: SetOrganizationFeatureRequest) { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (feature: string) => { + const client = useFetchClient(); + const response = await client.delete(`organizations/${request.route.id}/features/${feature}`); + return response.ok; + }, + mutationKey: queryKeys.setFeature(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) }); + } + })); +} + +export function setOrganizationFeature(request: SetOrganizationFeatureRequest) { + const queryClient = useQueryClient(); + return createMutation(() => ({ + mutationFn: async (feature: string) => { + const client = useFetchClient(); + const response = await client.post(`organizations/${request.route.id}/features/${feature}`); + return response.ok; + }, + mutationKey: queryKeys.setFeature(request.route.id), + onError: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id, undefined) }); + queryClient.invalidateQueries({ queryKey: queryKeys.list(undefined) }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts new file mode 100644 index 0000000000..859f82d602 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/api.svelte.ts @@ -0,0 +1,120 @@ +import { accessToken } from '$features/auth/index.svelte'; +import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; +import { type ProblemDetails, useFetchClient } from '@exceptionless/fetchclient'; +import { createMutation, createQuery, QueryClient, useQueryClient } from '@tanstack/svelte-query'; + +import type { NewSavedView, SavedView, UpdateSavedView } from './models'; + +// When a new saved view is added, Elasticsearch needs ~1s to index it. +// Without a delay, the background refetch triggered by this invalidation returns +// stale data that omits the new view, causing the URL param to be cleared. +export async function invalidateSavedViewQueries(queryClient: QueryClient, message: WebSocketMessageValue<'SavedViewChanged'>) { + const { change_type, organization_id } = message; + + if (change_type === ChangeType.Added) { + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + + if (organization_id) { + await queryClient.invalidateQueries({ queryKey: queryKeys.organization(organization_id) }); + } else { + await queryClient.invalidateQueries({ queryKey: queryKeys.type }); + } +} + +export const queryKeys = { + id: (id: string | undefined) => [...queryKeys.type, id] as const, + organization: (organizationId: string | undefined) => [...queryKeys.type, 'organization', organizationId] as const, + type: ['SavedView'] as const, + view: (organizationId: string | undefined, view: string | undefined) => [...queryKeys.type, 'organization', organizationId, 'view', view] as const +}; + +export function deleteSavedView(request: { route: { ids: string[] } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.ids?.length, + mutationFn: async () => { + const client = useFetchClient(); + await client.delete(`saved-views/${request.route.ids.join(',')}`, { + expectedStatusCodes: [202] + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.type }); + } + })); +} + +export function getSavedViewsByViewQuery(request: { route: { organizationId: string | undefined; view: string | undefined } }) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId && !!request.route.view, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views/${request.route.view}`, { signal }); + return response.data!; + }, + queryKey: queryKeys.view(request.route.organizationId, request.route.view), + // Saved views are managed via optimistic updates and WebSocket events. + // Disabling focus-triggered refetch prevents the race condition where a dialog + // closing fires a window focus event, causing a refetch that returns stale + // Elasticsearch data (1s indexing delay) and overwrites optimistic cache updates. + refetchOnWindowFocus: false + })); +} + +export function getSavedViewsQuery(request: { route: { organizationId: string | undefined } }) { + return createQuery(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON(`organizations/${request.route.organizationId}/saved-views`, { + signal + }); + return response.data!; + }, + queryKey: queryKeys.organization(request.route.organizationId) + })); +} + +export function patchSavedView(request: { route: { id: string | undefined } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.id, + mutationFn: async (data: UpdateSavedView) => { + const client = useFetchClient(); + const response = await client.patchJSON(`saved-views/${request.route.id}`, data); + return response.data!; + }, + onSuccess: (savedView: SavedView) => { + queryClient.invalidateQueries({ queryKey: queryKeys.organization(savedView.organization_id) }); + queryClient.invalidateQueries({ queryKey: queryKeys.id(request.route.id) }); + } + })); +} + +export function postSavedView(request: { route: { organizationId: string | undefined } }) { + const queryClient = useQueryClient(); + + return createMutation(() => ({ + enabled: () => !!accessToken.current && !!request.route.organizationId, + mutationFn: async (data: NewSavedView & { is_private?: boolean }) => { + const client = useFetchClient(); + const { is_private, ...body } = data; + const url = is_private + ? `organizations/${request.route.organizationId}/saved-views?is_private=true` + : `organizations/${request.route.organizationId}/saved-views`; + const response = await client.postJSON(url, body); + return response.data!; + }, + onSuccess: (savedView: SavedView) => { + // Optimistically populate the per-view cache so the new view is immediately + // available when handleSelect fires, before the background invalidation completes. + queryClient.setQueryData(queryKeys.view(request.route.organizationId, savedView.view), (old: SavedView[] | undefined) => + old ? [...old, savedView] : [savedView] + ); + queryClient.invalidateQueries({ queryKey: queryKeys.organization(request.route.organizationId) }); + } + })); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte new file mode 100644 index 0000000000..fba025766e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/components/saved-view-picker.svelte @@ -0,0 +1,615 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + {#if activeSavedView && isModified} + + Modified View + + + Update "{activeSavedView.name}" + + + + Save as new view + + + + Reset to saved + + + + {/if} + + {#if savedViews.length > 0} + + Saved Views + {#each sortedViews as savedView (savedView.id)} + handleSelect(savedView)}> + + + {#if effectiveView?.id === savedView.id} + + {/if} + + + + {savedView.name} + {#if savedView.is_default} + default + {/if} + {#if savedView.user_id} + private + {/if} + + {#if savedView.filter || savedView.time} + + + {#snippet child({ props: tipProps })} + + {formatViewSummary(savedView)} + + {/snippet} + + + {#if savedView.filter} +

{savedView.filter}

+ {/if} + {#if savedView.time} +

{timeLabels.get(savedView.time) ?? savedView.time}

+ {/if} +
+
+ {:else} + No filters + {/if} +
+
+ +
+ {/each} +
+ + {/if} + + + {#if duplicateView && !activeSavedView} + handleSelect(duplicateView)}> + + Load "{duplicateView.name}" (matches current) + + {/if} + {#if !effectiveView} + + + Save current view + + {/if} + {#if effectiveView} + + + Rename "{effectiveView.name}" + + {#if !effectiveView.user_id && !effectiveView.is_default} + + + Set as default for everyone + + {/if} + + openDeleteDialog(effectiveView)}> + + Delete "{effectiveView.name}" + + {#if !isModified} + + Clear Saved View + {/if} + {/if} + +
+
+ + +{#if saveDialogOpen} + + + + Save View + Save the current view configuration for quick access. + + {#if duplicateView} +
+

+ Current filters match "{duplicateView.name}". You can + instead, or save with a different name. +

+
+ {/if} +
{ + e.preventDefault(); + handleSave(); + }} + > +
+ + +
+
+
+ +

Only visible to you

+
+ { + if (checked) { + saveAsDefault = false; + } + }} + /> +
+ {#if !savePrivate} +
+
+ +

Auto-loads for everyone on page visit

+
+ +
+ {/if} + + + + +
+
+
+{/if} + + +{#if renameDialogOpen} + + + + Rename View + Change the display name for this saved view. + +
{ + e.preventDefault(); + handleRename(); + }} + > +
+ + +
+ + + + +
+
+
+{/if} + + +{#if deleteDialogOpen && deleteTarget} + + + + Delete Saved View + + Are you sure you want to delete "{deleteTarget.name}"? This action cannot be undone. + + + + Cancel + Delete + + + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts new file mode 100644 index 0000000000..67f34f7bdb --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/index.ts @@ -0,0 +1,3 @@ +export { deserializeFilters, serializeFilters } from '$features/events/components/filters/helpers.svelte'; +export { deleteSavedView, getSavedViewsByViewQuery, getSavedViewsQuery, patchSavedView, postSavedView, queryKeys as savedViewQueryKeys } from './api.svelte'; +export type { NewSavedView, SavedView, UpdateSavedView } from './models'; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts new file mode 100644 index 0000000000..f9d21ae00d --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/models.ts @@ -0,0 +1,13 @@ +import type { NewSavedView as GeneratedNewSavedView, UpdateSavedView as GeneratedUpdateSavedView, ViewSavedView } from '$generated/api'; + +export type NewSavedView = Omit & { + filter?: null | string; + is_default?: boolean; +}; + +export type SavedView = ViewSavedView; + +export type UpdateSavedView = Omit & { + columns?: Record; + is_default?: boolean; +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts new file mode 100644 index 0000000000..e8739567b0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/saved-views/use-saved-views.svelte.ts @@ -0,0 +1,248 @@ +import type { IFilter } from '$comp/faceted-filter'; +import type { VisibilityState } from '@tanstack/svelte-table'; + +import { deserializeFilters } from '$features/events/components/filters/helpers.svelte'; +import { getOrganizationQuery } from '$features/organizations/api.svelte'; +import { organization } from '$features/organizations/context.svelte'; +import { untrack } from 'svelte'; + +import type { SavedView } from './models'; + +import { getSavedViewsByViewQuery } from './api.svelte'; + +const SAVED_VIEWS_FEATURE = 'feature-saved-views'; + +export interface SavedViewQueryParams { + filter: null | string; + saved: null | string | undefined; + time?: null | string; +} + +export interface UseSavedViewsOptions { + filterCacheKey: (filter: null | string) => string; + getColumnVisibility?: () => VisibilityState; + queryParams: SavedViewQueryParams; + setColumnVisibility?: (visibility: VisibilityState) => void; + updateFilterCache: (key: string, filters: IFilter[]) => void; + view: string; +} + +export interface UseSavedViewsReturn { + activeSavedView: SavedView | undefined; + handleClearSavedView: () => void; + handleLoadView: (id: string) => void; + handleResetToSaved: () => void; + isEnabled: boolean; + isModified: boolean; + savedViews: SavedView[]; +} + +export function useSavedViews(options: UseSavedViewsOptions): UseSavedViewsReturn { + const organizationQuery = getOrganizationQuery({ + route: { + get id() { + return organization.current; + } + } + }); + + // Feature flag gate: only enable saved views if the organization has the feature + const isEnabled = $derived(organizationQuery.data?.features?.includes(SAVED_VIEWS_FEATURE) ?? false); + + const savedViewsListQuery = getSavedViewsByViewQuery({ + route: { + get organizationId() { + return isEnabled ? organization.current : undefined; + }, + get view() { + return options.view; + } + } + }); + + // Find the active saved view by ID from the list query (no separate fetch needed) + const activeSavedView = $derived.by(() => { + const savedId = options.queryParams.saved; + if (!savedId) { + return undefined; + } + + const views = savedViewsListQuery.data; + if (!views) { + return undefined; + } + + const found = views.find((v) => v.id === savedId); + return found; + }); + + // Hydrate filters/columns when a saved view loads, or clear params if the view is no longer found. + // lastHydratedId prevents re-hydration on background refetches (which would stomp user edits). + let lastHydratedId = ''; + let hasAttemptedRestore = false; + let lastRestoredOrganizationId = ''; + $effect(() => { + const savedId = options.queryParams.saved; + const isLoading = savedViewsListQuery.isLoading; + const isFetching = savedViewsListQuery.isFetching; + const views = savedViewsListQuery.data; + + if (!savedId || isLoading || !views) { + if (!savedId) { + lastHydratedId = ''; + } + + return; + } + + const view = views.find((v) => v.id === savedId); + + if (!view) { + // Skip while refetching to avoid false-positive clears during cache invalidation + if (isFetching) { + return; + } + + // View not found after a definitive load — clear params and allow auto-restore to re-run + untrack(() => { + options.queryParams.saved = null; + }); + options.queryParams.filter = null; + options.queryParams.time = null; + hasAttemptedRestore = false; + return; + } + + // Already hydrated this view — skip to avoid stomping user edits on background refetch + if (savedId === lastHydratedId) { + return; + } + + lastHydratedId = savedId; + + if (view.filter_definitions) { + const hydrated = deserializeFilters(view.filter_definitions); + options.updateFilterCache(options.filterCacheKey(view.filter ?? null), hydrated); + } + + options.queryParams.filter = view.filter ?? null; + options.queryParams.time = view.time ?? null; + + if (view.columns && options.setColumnVisibility) { + options.setColumnVisibility(view.columns); + } + }); + + // Auto-load default saved view when navigating to page without explicit params + $effect(() => { + const organizationId = organization.current; + const views = savedViewsListQuery.data; + if (!organizationId) { + return; + } + + if (organizationId !== lastRestoredOrganizationId) { + hasAttemptedRestore = false; + lastRestoredOrganizationId = organizationId; + } + if (hasAttemptedRestore) { + return; + } + if (savedViewsListQuery.isLoading) { + return; + } + + hasAttemptedRestore = true; + + const search = window.location.search; + const hasExplicitParams = /[?&]saved(?:[=&]|$)/.test(search) || /[?&]filter(?:[=&]|$)/.test(search) || /[?&]time(?:[=&]|$)/.test(search); + if (hasExplicitParams) { + return; + } + + untrack(() => { + const defaultView = views?.find((v) => v.is_default); + if (defaultView) { + options.queryParams.saved = defaultView.id; + } + }); + }); + + // Detect if current filters or columns differ from the active saved view + const isModified = $derived.by(() => { + const view = activeSavedView; + if (!view || !options.queryParams.saved) { + return false; + } + if ((options.queryParams.filter ?? null) !== (view.filter ?? null)) { + return true; + } + if (view.time && (options.queryParams.time ?? '') !== view.time) { + return true; + } + if (options.getColumnVisibility && !columnsEqual(options.getColumnVisibility(), view.columns)) { + return true; + } + return false; + }); + + function handleLoadView(id: string) { + options.queryParams.saved = id; + } + + function handleResetToSaved() { + const view = activeSavedView; + if (!view) { + return; + } + + if (view.filter_definitions) { + const hydrated = deserializeFilters(view.filter_definitions); + options.updateFilterCache(options.filterCacheKey(view.filter ?? null), hydrated); + } + options.queryParams.filter = view.filter ?? null; + options.queryParams.time = view.time ?? null; + if (view.columns && options.setColumnVisibility) { + options.setColumnVisibility(view.columns); + } + } + + function handleClearSavedView() { + options.queryParams.saved = null; + options.queryParams.filter = null; + options.queryParams.time = null; + if (options.setColumnVisibility) { + options.setColumnVisibility({}); + } + } + + return { + get activeSavedView() { + return activeSavedView; + }, + handleClearSavedView, + handleLoadView, + handleResetToSaved, + get isEnabled() { + return isEnabled; + }, + get isModified() { + return isModified; + }, + get savedViews() { + return savedViewsListQuery.data ?? []; + } + }; +} + +function columnsEqual(a: undefined | VisibilityState, b: null | Record | undefined): boolean { + const aEntries = Object.entries(a ?? {}).sort(([k1], [k2]) => k1.localeCompare(k2)); + const bEntries = Object.entries(b ?? {}).sort(([k1], [k2]) => k1.localeCompare(k2)); + if (aEntries.length !== bEntries.length) { + return false; + } + return aEntries.every(([k, v], i) => { + const bEntry = bEntries[i]; + return bEntry !== undefined && bEntry[0] === k && bEntry[1] === v; + }); +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 856890de12..1693999c37 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -123,11 +123,24 @@ export interface NewProject { delete_bot_data_enabled: boolean; } +export interface NewSavedView { + /** @pattern ^[a-fA-F0-9]{24}$ */ + organization_id: string; + name: string; + filter?: null | string; + time?: null | string; + /** @pattern ^(events|issues|stream)$ */ + view: string; + filter_definitions?: null | string; + columns?: null | Record; + is_default: boolean; +} + export interface NewToken { /** @pattern ^[a-fA-F0-9]{24}$ */ - organization_id?: null | string; + organization_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ - project_id?: null | string; + project_id: string; /** @pattern ^[a-fA-F0-9]{24}$ */ default_project_id?: null | string; scopes: string[]; @@ -196,7 +209,7 @@ export interface PersistentEvent { */ created_utc: string; /** Used to store primitive data type custom data values for searching the event. */ - idx: Record; + idx?: null | Record; /** The event type (ie. error, log message, feature usage). Check KnownTypes for standard event types. */ type?: null | string; /** The event source (ie. machine name, log name, feature name). */ @@ -342,6 +355,16 @@ export interface UpdateProject { delete_bot_data_enabled: boolean; } +/** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ +export interface UpdateSavedView { + name?: null | string; + filter?: null | string; + time?: null | string; + filter_definitions?: null | string; + columns: unknown[]; + is_default?: null | boolean; +} + /** A class the tracks changes (i.e. the Delta) for a particular TEntityType. */ export interface UpdateToken { is_disabled: boolean; @@ -474,6 +497,7 @@ export interface ViewOrganization { /** @format date-time */ suspension_date?: null | string; has_premium_features: boolean; + features: string[]; /** @format int32 */ max_users: number; /** @format int32 */ @@ -516,6 +540,32 @@ export interface ViewProject { usage: UsageInfo[]; } +export interface ViewSavedView { + /** @pattern ^[a-fA-F0-9]{24}$ */ + id: string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + organization_id: string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + user_id?: null | string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + created_by_user_id: string; + /** @pattern ^[a-fA-F0-9]{24}$ */ + updated_by_user_id?: null | string; + filter?: null | string; + filter_definitions?: null | string; + columns?: null | Record; + is_default: boolean; + name: string; + time?: null | string; + /** @format int32 */ + version: number; + view: string; + /** @format date-time */ + created_utc: string; + /** @format date-time */ + updated_utc: string; +} + export interface ViewToken { /** @pattern ^[a-fA-F0-9]{24}$ */ id: string; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts index 66e65ceef1..27843d2e1e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts @@ -161,17 +161,43 @@ export const NewProjectSchema = object({ }); export type NewProjectFormData = Infer; -export const NewTokenSchema = object({ +export const NewSavedViewSchema = object({ organization_id: string() .length(24, "Organization id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format") + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), + name: string() + .min(1, "Name is required") + .max(100, "Name must be at most 100 characters"), + filter: string() + .min(1, "Filter is required") + .max(2000, "Filter must be at most 2000 characters") .nullable() .optional(), - project_id: string() - .length(24, "Project id must be exactly 24 characters") - .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format") + time: string() + .min(1, "Time is required") + .max(100, "Time must be at most 100 characters") + .nullable() + .optional(), + view: string() + .min(1, "View is required") + .regex(/^(events|issues|stream)$/, "View has invalid format"), + filter_definitions: string() + .min(1, "Filter definitions is required") + .max(10000, "Filter definitions must be at most 10000 characters") .nullable() .optional(), + columns: record(string(), boolean()).nullable().optional(), + is_default: boolean(), +}); +export type NewSavedViewFormData = Infer; + +export const NewTokenSchema = object({ + organization_id: string() + .length(24, "Organization id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), + project_id: string() + .length(24, "Project id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Project id has invalid format"), default_project_id: string() .length(24, "Default project id must be exactly 24 characters") .regex(/^[a-fA-F0-9]{24}$/, "Default project id has invalid format") @@ -243,7 +269,7 @@ export const PersistentEventSchema = object({ .regex(/^[a-fA-F0-9]{24}$/, "Stack id has invalid format"), is_first_occurrence: boolean(), created_utc: iso.datetime(), - idx: record(string(), unknown()), + idx: record(string(), unknown()).nullable().optional(), type: string() .min(1, "Type is required") .max(100, "Type must be at most 100 characters") @@ -372,6 +398,19 @@ export const UpdateProjectSchema = object({ }); export type UpdateProjectFormData = Infer; +export const UpdateSavedViewSchema = object({ + name: string().min(1, "Name is required").nullable().optional(), + filter: string().min(1, "Filter is required").nullable().optional(), + time: string().min(1, "Time is required").nullable().optional(), + filter_definitions: string() + .min(1, "Filter definitions is required") + .nullable() + .optional(), + columns: array(unknown()).optional(), + is_default: boolean().nullable().optional(), +}); +export type UpdateSavedViewFormData = Infer; + export const UpdateTokenSchema = object({ is_disabled: boolean().optional(), notes: string().min(1, "Notes is required").nullable().optional(), @@ -492,6 +531,7 @@ export const ViewOrganizationSchema = object({ .optional(), suspension_date: iso.datetime().nullable().optional(), has_premium_features: boolean(), + features: array(string()), max_users: int32(), max_projects: int32(), project_count: int(), @@ -530,6 +570,42 @@ export const ViewProjectSchema = object({ }); export type ViewProjectFormData = Infer; +export const ViewSavedViewSchema = object({ + id: string() + .length(24, "Id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Id has invalid format"), + organization_id: string() + .length(24, "Organization id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Organization id has invalid format"), + user_id: string() + .length(24, "User id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "User id has invalid format") + .nullable() + .optional(), + created_by_user_id: string() + .length(24, "Created by user id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Created by user id has invalid format"), + updated_by_user_id: string() + .length(24, "Updated by user id must be exactly 24 characters") + .regex(/^[a-fA-F0-9]{24}$/, "Updated by user id has invalid format") + .nullable() + .optional(), + filter: string().min(1, "Filter is required").nullable().optional(), + filter_definitions: string() + .min(1, "Filter definitions is required") + .nullable() + .optional(), + columns: record(string(), boolean()).nullable().optional(), + is_default: boolean(), + name: string().min(1, "Name is required"), + time: string().min(1, "Time is required").nullable().optional(), + version: int32(), + view: string().min(1, "View is required"), + created_utc: iso.datetime(), + updated_utc: iso.datetime(), +}); +export type ViewSavedViewFormData = Infer; + export const ViewTokenSchema = object({ id: string() .length(24, "Id must be exactly 24 characters") diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte index e8a486d5f2..8faf1bae16 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte @@ -48,16 +48,70 @@ {#each dashboardRoutes as route (route.href)} {@const Icon = route.icon} - - - {#snippet child({ props })} - - - {route.title} - + {#if route.children?.length} + page.url.href.includes(c.href))} + class="group/collapsible" + > + {#snippet child({ props: collapsibleProps })} + + + {#snippet child({ props: triggerProps })} + + {#snippet child({ props: buttonProps })} + + + {route.title} + + + {/snippet} + + {/snippet} + + + + {#each route.children as savedItem (savedItem.href)} + {@const savedId = new URL(savedItem.href, page.url.origin).searchParams.get('saved')} + + + {#snippet child({ props: subProps })} + + {savedItem.title} + + {/snippet} + + + {/each} + + + {/snippet} - - + + {:else} + + + {#snippet child({ props })} + + + {route.title} + + {/snippet} + + + {/if} {/each} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index eb7e0da037..a6c0ed6ebe 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -1,4 +1,6 @@ + +{#if organizationQuery.isError} + +{:else if !isGlobalAdmin} + +{:else} +
+
+

Features

+ Enable or disable features for this organization. +
+ + +
+ {#if organizationQuery.isLoading} + {#each Array.from({ length: KNOWN_FEATURES.length }, (_, index) => index) as i (`skeleton-${i}`)} +
+
+
+ + +
+ +
+
+ {/each} + {:else} + {#each KNOWN_FEATURES as feature (feature.id)} +
+
+
+
{feature.name}
+ {feature.description} +
+ handleToggleFeature(feature.id, checked)} + /> +
+
+ {/each} + {/if} +
+
+{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts index 8b170727d6..5c215a6cd3 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/routes.svelte.ts @@ -6,6 +6,7 @@ import Billing from '@lucide/svelte/icons/credit-card'; import Folder from '@lucide/svelte/icons/folder'; import Settings from '@lucide/svelte/icons/settings'; import Users from '@lucide/svelte/icons/users'; +import Zap from '@lucide/svelte/icons/zap'; import type { NavigationItem } from '../../../routes.svelte'; @@ -47,6 +48,13 @@ export function routes(): NavigationItem[] { icon: Billing, title: 'Billing' }, + { + group: 'Organization Settings', + href: resolve('/(app)/organization/[organizationId]/features', { organizationId }), + icon: Zap, + show: (ctx) => !!ctx.user?.roles?.includes('global'), + title: 'Features' + }, { group: 'Settings', href: resolve('/(app)/organization/[organizationId]/projects', { organizationId }), diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte index 5b81d822c7..bd9e8cf67f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -28,6 +28,8 @@ import OrganizationDefaultsFacetedFilterBuilder from '$features/events/components/filters/organization-defaults-faceted-filter-builder.svelte'; import { getColumns } from '$features/events/components/table/options.svelte'; import { organization } from '$features/organizations/context.svelte'; + import SavedViewPicker from '$features/saved-views/components/saved-view-picker.svelte'; + import { useSavedViews } from '$features/saved-views/use-saved-views.svelte'; import { getSharedTableOptions, isTableEmpty, removeTableData } from '$features/shared/table.svelte'; import { StackStatus } from '$features/stacks/models'; import { ChangeType, type WebSocketMessageValue } from '$features/websockets/models'; @@ -42,6 +44,7 @@ import { redirectToEventsWithFilter } from '../redirect-to-events.svelte'; let selectedEventId: null | string = $state(null); + function rowclick(row: EventSummaryModel) { selectedEventId = row.id; } @@ -53,7 +56,8 @@ const DEFAULT_FILTERS = [new ProjectFilter([]), new StatusFilter([StackStatus.Open, StackStatus.Regressed])]; const DEFAULT_PARAMS = { filter: '(status:open OR status:regressed)', - limit: DEFAULT_LIMIT + limit: DEFAULT_LIMIT, + saved: undefined as string | undefined }; function filterCacheKey(filter: null | string): string { @@ -66,15 +70,25 @@ pushHistory: true, schema: { filter: 'string', - limit: 'number' + limit: 'number', + saved: 'string' } }); + const VIEW = 'stream'; + const savedViewsState = useSavedViews({ + filterCacheKey, + getColumnVisibility: () => table.getState().columnVisibility, + queryParams, + setColumnVisibility: (v) => table.setColumnVisibility(v), + updateFilterCache, + view: VIEW + }); + watch( () => organization.current, () => { updateFilterCache(filterCacheKey(DEFAULT_PARAMS.filter), DEFAULT_FILTERS); - //params.$reset(); // Work around for https://github.com/beynar/kit-query-params/issues/7 Object.assign(queryParams, DEFAULT_PARAMS); paused = false; }, @@ -91,13 +105,11 @@ ); async function onFilterChanged(addedOrUpdated: FacetedFilter.IFilter) { - // If this is a stack filter, redirect to the Events page if (addedOrUpdated.type === 'string' && addedOrUpdated.key === 'string-stack') { await redirectToEventsWithFilter(organization.current, addedOrUpdated); return; } - // For all other filters (skipping date filters), apply them to the current page if (addedOrUpdated.type !== 'date') { updateFilters(filterChanged(filters ?? [], addedOrUpdated)); } @@ -253,6 +265,19 @@
+ {#if savedViewsState.isEnabled} + + {/if}
diff --git a/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts index 515d5a67fc..348e76d93d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts @@ -6,13 +6,22 @@ import type { Component } from 'svelte'; import { routes as appRoutes } from './(app)/routes.svelte'; import { routes as authRoutes } from './(auth)/routes.svelte'; +export type NavigationChild = { + href: string; + isDefault?: boolean; + title: string; +}; + export type NavigationItem = { + children?: NavigationChild[]; + defaultViewId?: string; group: string; href: ResolvedPathname | string; icon: Component | typeof Icon; openInNewTab?: boolean; show?: (context: NavigationItemContext) => boolean; title: string; + view?: string; }; export type NavigationItemContext = { diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index 905451ad6c..cc424c0ea3 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -598,6 +598,7 @@ public async Task RemoveUserAsync(string id, string email) user.OrganizationIds.Remove(organization.Id); await _userRepository.SaveAsync(user, o => o.Cache()); + await _organizationService.RemoveUserSavedViewsAsync(organization.Id, user.Id); await _messagePublisher.PublishAsync(new UserMembershipChanged { ChangeType = ChangeType.Removed, @@ -696,6 +697,52 @@ public async Task DeleteDataAsync(string id, string key) return Ok(); } + /// + /// Enable a feature flag + /// + /// The identifier of the organization. + /// The feature flag identifier (e.g., "feature-saved-views"). + /// The feature flag was enabled. + /// The organization was not found. + [HttpPost] + [Route("{id:objectid}/features/{feature:minlength(1)}")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task SetFeatureAsync(string id, string feature) + { + var organization = await GetModelAsync(id, false); + if (organization is null) + return NotFound(); + + organization.Features.Add(feature.Trim().ToLowerInvariant()); + await _repository.SaveAsync(organization, o => o.Cache()); + + return Ok(); + } + + /// + /// Disable a feature flag + /// + /// The identifier of the organization. + /// The feature flag identifier (e.g., "feature-saved-views"). + /// The feature flag was disabled. + /// The organization was not found. + [HttpDelete] + [Route("{id:objectid}/features/{feature:minlength(1)}")] + [Authorize(Policy = AuthorizationRoles.GlobalAdminPolicy)] + [ApiExplorerSettings(IgnoreApi = true)] + public async Task RemoveFeatureAsync(string id, string feature) + { + var organization = await GetModelAsync(id, false); + if (organization is null) + return NotFound(); + + if (organization.Features.Remove(feature.Trim().ToLowerInvariant())) + await _repository.SaveAsync(organization, o => o.Cache()); + + return Ok(); + } + /// /// Check for unique name /// diff --git a/src/Exceptionless.Web/Controllers/SavedViewController.cs b/src/Exceptionless.Web/Controllers/SavedViewController.cs new file mode 100644 index 0000000000..1def73b681 --- /dev/null +++ b/src/Exceptionless.Web/Controllers/SavedViewController.cs @@ -0,0 +1,317 @@ +using Exceptionless.Core.Authorization; +using Exceptionless.Core.Extensions; +using Exceptionless.Core.Models; +using Exceptionless.Core.Queries.Validation; +using Exceptionless.Core.Repositories; +using Exceptionless.Web.Controllers; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Exceptionless.Web.Utility; +using Foundatio.Repositories; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Exceptionless.App.Controllers.API; + +[Route(API_PREFIX + "/saved-views")] +[Authorize(Policy = AuthorizationRoles.UserPolicy)] +public class SavedViewController : RepositoryApiController +{ + private const int MaxViewsPerOrganization = 100; + private bool _isPrivateRequest; + private readonly IOrganizationRepository _organizationRepository; + + public SavedViewController( + ISavedViewRepository repository, + IOrganizationRepository organizationRepository, + ApiMapper mapper, + IAppQueryValidator validator, + TimeProvider timeProvider, + ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory) + { + _organizationRepository = organizationRepository; + } + + protected override SavedView MapToModel(NewSavedView newModel) => _mapper.MapToSavedView(newModel); + protected override ViewSavedView MapToViewModel(SavedView model) => _mapper.MapToViewSavedView(model); + protected override List MapToViewModels(IEnumerable models) => _mapper.MapToViewSavedViews(models); + + private async Task IsFeatureEnabledAsync(string organizationId) + { + var org = await _organizationRepository.GetByIdAsync(organizationId, o => o.Cache()); + return org?.Features?.Contains(OrganizationFeatures.SavedViews) ?? false; + } + + /// + /// Get by organization + /// + /// The identifier of the organization. + /// The page parameter is used for pagination. This value must be greater than 0. + /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. + /// The organization could not be found. + [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views")] + public async Task>> GetByOrganizationAsync(string organizationId, int page = 1, int limit = 25) + { + if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) + { + return NotFound(); + } + + // Reads remain available even when the feature is disabled to preserve access to existing saved views. + + page = GetPage(page); + limit = GetLimit(limit); + var results = await _repository.GetByOrganizationForUserAsync(organizationId, CurrentUser.Id, o => o.PageNumber(page).PageLimit(limit)); + + var viewModels = MapToViewModels(results.Documents); + return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + /// + /// Get by organization and view + /// + /// The identifier of the organization. + /// The dashboard view (events, issues, stream). + /// The page parameter is used for pagination. This value must be greater than 0. + /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. + /// The organization could not be found. + [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views/{view}")] + public async Task>> GetByViewAsync(string organizationId, string view, int page = 1, int limit = 25) + { + if (String.IsNullOrEmpty(organizationId) || !CanAccessOrganization(organizationId)) + { + return NotFound(); + } + + if (!SavedView.ValidViews.Contains(view)) + { + return NotFound(); + } + + // Reads remain available even when the feature is disabled to preserve access to existing saved views. + + page = GetPage(page); + limit = GetLimit(limit); + var results = await _repository.GetByViewForUserAsync(organizationId, view, CurrentUser.Id, o => o.PageNumber(page).PageLimit(limit)); + + var viewModels = MapToViewModels(results.Documents); + return OkWithResourceLinks(viewModels, results.HasMore && !NextPageExceedsSkipLimit(page, limit), page, results.Total); + } + + /// + /// Get by id + /// + /// The identifier of the saved view. + /// The saved view could not be found. + [HttpGet("{id:objectid}", Name = "GetSavedViewById")] + public Task> GetAsync(string id) + { + return GetByIdImplAsync(id); + } + + /// + /// Create + /// + /// The identifier of the organization. + /// The saved view. + /// If true, the view will only be visible to the current user. + /// An error occurred while creating the saved view. + /// The saved view already exists. + [HttpPost("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/saved-views")] + [Consumes("application/json")] + [ProducesResponseType(StatusCodes.Status201Created)] + public async Task> PostAsync(string organizationId, NewSavedView savedView, [FromQuery] bool is_private = false) + { + if (!IsInOrganization(organizationId)) + { + return BadRequest(); + } + + if (is_private && savedView.IsDefault) + { + ModelState.AddModelError(nameof(NewSavedView.IsDefault), "Private views cannot be set as the default. Default views are organization-wide."); + return ValidationProblem(ModelState); + } + + savedView.OrganizationId = organizationId; + _isPrivateRequest = is_private; + return await PostImplAsync(savedView); + } + + /// + /// Update + /// + /// The identifier of the saved view. + /// The changes + /// An error occurred while updating the saved view. + /// The saved view could not be found. + [HttpPatch("{id:objectid}")] + [HttpPut("{id:objectid}")] + [Consumes("application/json")] + public Task> PatchAsync(string id, Delta changes) + { + return PatchImplAsync(id, changes); + } + + /// + /// Remove + /// + /// A comma-delimited list of saved view identifiers. + /// Accepted + /// One or more validation errors occurred. + /// One or more saved views were not found. + /// An error occurred while deleting one or more saved views. + [HttpDelete("{ids:objectids}")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + public Task> DeleteAsync(string ids) + { + return DeleteImplAsync(ids.FromDelimitedString()); + } + + protected override async Task GetModelAsync(string id, bool useCache = true) + { + if (String.IsNullOrEmpty(id)) + { + return null; + } + + var model = await _repository.GetByIdAsync(id, o => o.Cache(useCache)); + if (model is null) + { + return null; + } + + if (!String.IsNullOrEmpty(model.OrganizationId) && !IsInOrganization(model.OrganizationId)) + { + return null; + } + + if (model.UserId is not null && model.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) + { + return null; + } + + return model; + } + + protected override async Task CanAddAsync(SavedView value) + { + if (String.IsNullOrEmpty(value.OrganizationId) || !IsInOrganization(value.OrganizationId)) + { + return PermissionResult.Deny; + } + + if (!await IsFeatureEnabledAsync(value.OrganizationId)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); + } + + var count = await _repository.CountByOrganizationIdAsync(value.OrganizationId); + if (count >= MaxViewsPerOrganization) + { + return PermissionResult.DenyWithMessage($"Organization is limited to {MaxViewsPerOrganization} saved views."); + } + + return await base.CanAddAsync(value); + } + + protected override async Task CanUpdateAsync(SavedView original, Delta changes) + { + if (original.UserId is not null && original.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) + { + return PermissionResult.DenyWithNotFound(original.Id); + } + + if (!await IsFeatureEnabledAsync(original.OrganizationId)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); + } + + // Private views cannot be set as the default + if (original.UserId is not null + && changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.IsDefault)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.IsDefault), out object? isDefaultValue) + && isDefaultValue is true) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "Private views cannot be set as the default. Default views are organization-wide."); + } + + if (changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.Columns))) + { + var patchedChanges = new UpdateSavedView(); + changes.Patch(patchedChanges); + var validationError = NewSavedView.ValidateColumnKeys(original.View, patchedChanges.Columns).FirstOrDefault(); + if (validationError is not null) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, validationError.ErrorMessage ?? "Invalid column keys."); + } + } + + return await base.CanUpdateAsync(original, changes); + } + + protected override async Task AddModelAsync(SavedView value) + { + value.CreatedUtc = value.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + value.CreatedByUserId = CurrentUser.Id; + value.Version = 1; + + if (_isPrivateRequest) + { + value.UserId = CurrentUser.Id; + } + + if (value.IsDefault) + { + await ClearDefaultForViewAsync(value.OrganizationId, value.View); + } + + return await base.AddModelAsync(value); + } + + protected override async Task UpdateModelAsync(SavedView original, Delta changes) + { + original.UpdatedUtc = _timeProvider.GetUtcNow().UtcDateTime; + original.UpdatedByUserId = CurrentUser.Id; + + if (changes.GetChangedPropertyNames().Contains(nameof(UpdateSavedView.IsDefault)) + && changes.TryGetPropertyValue(nameof(UpdateSavedView.IsDefault), out object? isDefaultValue) + && isDefaultValue is true) + { + await ClearDefaultForViewAsync(original.OrganizationId, original.View); + } + + return await base.UpdateModelAsync(original, changes); + } + + private async Task ClearDefaultForViewAsync(string organizationId, string view) + { + var existing = await _repository.GetByViewAsync(organizationId, view, o => o.ImmediateConsistency()); + var defaults = existing.Documents.Where(savedView => savedView.IsDefault && savedView.UserId is null).ToList(); + + if (defaults.Count > 0) + { + foreach (var defaultView in defaults) + { + defaultView.IsDefault = false; + } + + await _repository.SaveAsync(defaults, o => o.ImmediateConsistency()); + } + } + + protected override async Task CanDeleteAsync(SavedView value) + { + if (value.UserId is not null && value.UserId != CurrentUser.Id && !User.IsInRole(AuthorizationRoles.GlobalAdmin)) + { + return PermissionResult.DenyWithNotFound(value.Id); + } + + if (!await IsFeatureEnabledAsync(value.OrganizationId)) + { + return PermissionResult.DenyWithStatus(StatusCodes.Status422UnprocessableEntity, "The saved views feature is not enabled for this organization."); + } + + return await base.CanDeleteAsync(value); + } +} diff --git a/src/Exceptionless.Web/Mapping/ApiMapper.cs b/src/Exceptionless.Web/Mapping/ApiMapper.cs index 450c22a472..3038018784 100644 --- a/src/Exceptionless.Web/Mapping/ApiMapper.cs +++ b/src/Exceptionless.Web/Mapping/ApiMapper.cs @@ -15,6 +15,7 @@ public class ApiMapper private readonly UserMapper _userMapper; private readonly WebHookMapper _webHookMapper; private readonly InvoiceMapper _invoiceMapper; + private readonly SavedViewMapper _savedViewMapper; public ApiMapper(TimeProvider timeProvider) { @@ -24,6 +25,7 @@ public ApiMapper(TimeProvider timeProvider) _userMapper = new UserMapper(); _webHookMapper = new WebHookMapper(); _invoiceMapper = new InvoiceMapper(); + _savedViewMapper = new SavedViewMapper(); } // Organization mappings @@ -73,4 +75,14 @@ public InvoiceGridModel MapToInvoiceGridModel(Stripe.Invoice source) public List MapToInvoiceGridModels(IEnumerable source) => _invoiceMapper.MapToInvoiceGridModels(source); + + // SavedView mappings + public SavedView MapToSavedView(NewSavedView source) + => _savedViewMapper.MapToSavedView(source); + + public ViewSavedView MapToViewSavedView(SavedView source) + => _savedViewMapper.MapToViewSavedView(source); + + public List MapToViewSavedViews(IEnumerable source) + => _savedViewMapper.MapToViewSavedViews(source); } diff --git a/src/Exceptionless.Web/Mapping/SavedViewMapper.cs b/src/Exceptionless.Web/Mapping/SavedViewMapper.cs new file mode 100644 index 0000000000..f67863d474 --- /dev/null +++ b/src/Exceptionless.Web/Mapping/SavedViewMapper.cs @@ -0,0 +1,19 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Models; +using Riok.Mapperly.Abstractions; + +namespace Exceptionless.Web.Mapping; + +[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] +public partial class SavedViewMapper +{ + [MapperIgnoreTarget(nameof(SavedView.Version))] + [MapperIgnoreTarget(nameof(SavedView.CreatedByUserId))] + [MapperIgnoreTarget(nameof(SavedView.UpdatedByUserId))] + [MapperIgnoreTarget(nameof(SavedView.UserId))] + public partial SavedView MapToSavedView(NewSavedView source); + + public partial ViewSavedView MapToViewSavedView(SavedView source); + + public partial List MapToViewSavedViews(IEnumerable source); +} diff --git a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs index 9960f29622..6f6270a1e5 100644 --- a/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs +++ b/src/Exceptionless.Web/Models/Organization/ViewOrganization.cs @@ -31,6 +31,7 @@ public record ViewOrganization : IIdentity, IData, IHaveDates public string? SuspensionNotes { get; set; } public DateTime? SuspensionDate { get; set; } public bool HasPremiumFeatures { get; set; } + public ISet Features { get; set; } = new HashSet(); public int MaxUsers { get; set; } public int MaxProjects { get; set; } public long ProjectCount { get; set; } diff --git a/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs new file mode 100644 index 0000000000..56a829f19e --- /dev/null +++ b/src/Exceptionless.Web/Models/SavedView/NewSavedView.cs @@ -0,0 +1,93 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Exceptionless.Core.Attributes; +using Exceptionless.Core.Models; + +namespace Exceptionless.Web.Models; + +public record NewSavedView : IOwnedByOrganization, IValidatableObject +{ + [ObjectId] + public string OrganizationId { get; set; } = null!; + + [Required] + [MaxLength(100)] + public string Name { get; set; } = null!; + + [MaxLength(2000)] + public string? Filter { get; set; } + + [MaxLength(100)] + public string? Time { get; set; } + + [Required] + [RegularExpression("^(events|issues|stream)$")] + public string View { get; set; } = null!; + + [MaxLength(10000)] + public string? FilterDefinitions { get; set; } + + [MaxLength(50)] + public Dictionary? Columns { get; set; } + + public bool IsDefault { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!String.IsNullOrEmpty(View) && !SavedView.ValidViews.Contains(View)) + { + yield return new ValidationResult( + $"View must be one of: {String.Join(", ", SavedView.ValidViews)}", + [nameof(View)] + ); + } + + if (!String.IsNullOrEmpty(FilterDefinitions) && !IsValidJsonArray(FilterDefinitions)) + { + yield return new ValidationResult( + "FilterDefinitions must be a valid JSON array", + [nameof(FilterDefinitions)] + ); + } + + foreach (var error in ValidateColumnKeys(View, Columns)) + { + yield return error; + } + } + + internal static IEnumerable ValidateColumnKeys(string? view, Dictionary? columns) + { + if (columns is null || columns.Count == 0) + { + yield break; + } + + var validKeys = view is not null && SavedView.ValidColumnIds.TryGetValue(view, out var viewKeys) + ? viewKeys + : SavedView.AllValidColumnIds; + + var invalidKeys = columns.Keys.Where(key => !validKeys.Contains(key)); + foreach (var key in invalidKeys) + { + yield return new ValidationResult( + $"Column key '{key}' is not a valid column. Valid columns are: {String.Join(", ", validKeys.Order())}.", + [nameof(Columns)] + ); + } + } + + private static bool IsValidJsonArray(string json) + { + try + { + using var document = JsonDocument.Parse(json); + + return document.RootElement.ValueKind == JsonValueKind.Array; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs new file mode 100644 index 0000000000..d78324854d --- /dev/null +++ b/src/Exceptionless.Web/Models/SavedView/UpdateSavedView.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Exceptionless.Web.Models; + +public class UpdateSavedView : IValidatableObject +{ + [MaxLength(100)] + public string? Name { get; set; } + [MaxLength(2000)] + public string? Filter { get; set; } + [MaxLength(100)] + public string? Time { get; set; } + [MaxLength(10000)] + public string? FilterDefinitions { get; set; } + [MaxLength(50)] + public Dictionary? Columns { get; set; } + public bool? IsDefault { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + foreach (var error in NewSavedView.ValidateColumnKeys(null, Columns)) + { + yield return error; + } + } +} diff --git a/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs new file mode 100644 index 0000000000..c597d30758 --- /dev/null +++ b/src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs @@ -0,0 +1,34 @@ +using Exceptionless.Core.Attributes; +using Foundatio.Repositories.Models; + +namespace Exceptionless.Web.Models; + +public record ViewSavedView : IIdentity, IHaveDates +{ + [ObjectId] + public string Id { get; set; } = null!; + + [ObjectId] + public string OrganizationId { get; set; } = null!; + + [ObjectId] + public string? UserId { get; set; } + + [ObjectId] + public string CreatedByUserId { get; set; } = null!; + + [ObjectId] + public string? UpdatedByUserId { get; set; } + + public string? Filter { get; set; } + public string? FilterDefinitions { get; set; } + public Dictionary? Columns { get; set; } + public bool IsDefault { get; set; } + public string Name { get; set; } = null!; + public string? Time { get; set; } + public int Version { get; set; } + public string View { get; set; } = null!; + + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/tests/Exceptionless.Tests/Controllers/Data/openapi.json b/tests/Exceptionless.Tests/Controllers/Data/openapi.json index 5756ec39bf..ddeeee494e 100644 --- a/tests/Exceptionless.Tests/Controllers/Data/openapi.json +++ b/tests/Exceptionless.Tests/Controllers/Data/openapi.json @@ -20,6 +20,373 @@ } ], "paths": { + "/api/v2/organizations/{organizationId}/saved-views": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get by organization", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + } + }, + "404": { + "description": "The organization could not be found." + } + } + }, + "post": { + "tags": [ + "SavedView" + ], + "summary": "Create", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "is_private", + "in": "query", + "description": "If true, the view will only be visible to the current user.", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "requestBody": { + "description": "The saved view.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewSavedView" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/NewSavedView" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "400": { + "description": "An error occurred while creating the saved view." + }, + "409": { + "description": "The saved view already exists." + } + } + } + }, + "/api/v2/organizations/{organizationId}/saved-views/{view}": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get by organization and view", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "The identifier of the organization.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + }, + { + "name": "view", + "in": "path", + "description": "The dashboard view (events, issues, stream).", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "description": "The page parameter is used for pagination. This value must be greater than 0.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "limit", + "in": "query", + "description": "A limit on the number of objects to be returned. Limit can range between 1 and 100 items.", + "schema": { + "type": "integer", + "format": "int32", + "default": 25 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + } + }, + "404": { + "description": "The organization could not be found." + } + } + } + }, + "/api/v2/saved-views/{id}": { + "get": { + "tags": [ + "SavedView" + ], + "summary": "Get by id", + "operationId": "GetSavedViewById", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "404": { + "description": "The saved view could not be found." + } + } + }, + "patch": { + "tags": [ + "SavedView" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "400": { + "description": "An error occurred while updating the saved view." + }, + "404": { + "description": "The saved view could not be found." + } + } + }, + "put": { + "tags": [ + "SavedView" + ], + "summary": "Update", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The identifier of the saved view.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}$", + "type": "string" + } + } + ], + "requestBody": { + "description": "The changes", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateSavedView" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ViewSavedView" + } + } + } + }, + "400": { + "description": "An error occurred while updating the saved view." + }, + "404": { + "description": "The saved view could not be found." + } + } + } + }, + "/api/v2/saved-views/{ids}": { + "delete": { + "tags": [ + "SavedView" + ], + "summary": "Remove", + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "A comma-delimited list of saved view identifiers.", + "required": true, + "schema": { + "pattern": "^[a-zA-Z\\d]{24,36}(,[a-zA-Z\\d]{24,36})*$", + "type": "string" + } + } + ], + "responses": { + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkInProgressResult" + } + } + } + }, + "400": { + "description": "One or more validation errors occurred." + }, + "404": { + "description": "One or more saved views were not found." + }, + "500": { + "description": "An error occurred while deleting one or more saved views." + } + } + } + }, "/api/v2/organizations/{organizationId}/tokens": { "get": { "tags": [ @@ -7304,6 +7671,65 @@ } } }, + "NewSavedView": { + "required": [ + "name", + "view", + "organization_id", + "is_default" + ], + "type": "object", + "properties": { + "organization_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "name": { + "maxLength": 100, + "type": "string" + }, + "filter": { + "maxLength": 2000, + "type": [ + "null", + "string" + ] + }, + "time": { + "maxLength": 100, + "type": [ + "null", + "string" + ] + }, + "view": { + "pattern": "^(events|issues|stream)$", + "type": "string" + }, + "filter_definitions": { + "maxLength": 10000, + "type": [ + "null", + "string" + ] + }, + "columns": { + "maxLength": 50, + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } + }, + "is_default": { + "type": "boolean" + } + } + }, "NewToken": { "required": [ "organization_id", @@ -7905,6 +8331,45 @@ }, "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." }, + "UpdateSavedView": { + "type": "object", + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "filter": { + "type": [ + "null", + "string" + ] + }, + "time": { + "type": [ + "null", + "string" + ] + }, + "filter_definitions": { + "type": [ + "null", + "string" + ] + }, + "columns": { + "type": "array" + }, + "is_default": { + "type": [ + "null", + "boolean" + ] + } + }, + "description": "A class the tracks changes (i.e. the Delta) for a particular TEntityType." + }, "UpdateToken": { "type": "object", "properties": { @@ -8221,6 +8686,7 @@ "retention_days", "is_suspended", "has_premium_features", + "features", "max_users", "max_projects", "project_count", @@ -8341,6 +8807,13 @@ "has_premium_features": { "type": "boolean" }, + "features": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + } + }, "max_users": { "type": "integer", "format": "int32" @@ -8488,6 +8961,106 @@ } } }, + "ViewSavedView": { + "required": [ + "id", + "organization_id", + "created_by_user_id", + "is_default", + "name", + "version", + "view", + "created_utc", + "updated_utc" + ], + "type": "object", + "properties": { + "id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "organization_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "user_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ] + }, + "created_by_user_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": "string" + }, + "updated_by_user_id": { + "maxLength": 24, + "minLength": 24, + "pattern": "^[a-fA-F0-9]{24}$", + "type": [ + "null", + "string" + ] + }, + "filter": { + "type": [ + "null", + "string" + ] + }, + "filter_definitions": { + "type": [ + "null", + "string" + ] + }, + "columns": { + "type": [ + "null", + "object" + ], + "additionalProperties": { + "type": "boolean" + } + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "time": { + "type": [ + "null", + "string" + ] + }, + "version": { + "type": "integer", + "format": "int32" + }, + "view": { + "type": "string" + }, + "created_utc": { + "type": "string", + "format": "date-time" + }, + "updated_utc": { + "type": "string", + "format": "date-time" + } + } + }, "ViewToken": { "required": [ "id", @@ -8717,6 +9290,9 @@ } }, "tags": [ + { + "name": "SavedView" + }, { "name": "Token" }, diff --git a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs index 9514a29281..82d12d3d1d 100644 --- a/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/OrganizationControllerTests.cs @@ -214,6 +214,130 @@ public Task GetAsync_NonExistentOrganization_ReturnsNotFound() ); } + [Fact] + public async Task SetFeatureAsync_AsGlobalAdmin_EnablesFeature() + { + // Act + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert - feature is stored on the organization + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + Assert.Contains("feature-saved-views", organization.Features); + } + + [Fact] + public Task SetFeatureAsync_AsRegularUser_ReturnsForbidden() + { + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeForbidden() + ); + } + + [Fact] + public Task SetFeatureAsync_NonExistentOrganization_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", "000000000000000000000001", "features", "feature-saved-views") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task RemoveFeatureAsync_AsGlobalAdmin_DisablesFeature() + { + // Arrange - enable the feature first + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + var afterEnable = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(afterEnable); + Assert.Contains("feature-saved-views", afterEnable.Features); + + // Act - disable the feature + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert - feature is removed + var afterRemove = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(afterRemove); + Assert.DoesNotContain("feature-saved-views", afterRemove.Features); + } + + [Fact] + public Task RemoveFeatureAsync_AsRegularUser_ReturnsForbidden() + { + // Act & Assert + return SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeForbidden() + ); + } + + [Fact] + public async Task SetFeatureAsync_IsCaseInsensitive() + { + // Act - enable with different casing (controller normalizes to lowercase) + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "Feature-Saved-Views") + .StatusCodeShouldBeOk() + ); + + // Assert - stored normalized to lowercase + var organization = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + Assert.Contains("feature-saved-views", organization.Features); + Assert.DoesNotContain("Feature-Saved-Views", organization.Features); + } + + [Fact] + public async Task GetAsync_ViewOrganization_IncludesFeaturesCollection() + { + // Arrange - enable a feature + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "features", "feature-saved-views") + .StatusCodeShouldBeOk() + ); + + // Act + var viewOrg = await SendRequestAsAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID) + .StatusCodeShouldBeOk() + ); + + // Assert - Features is included in the ViewOrganization DTO + Assert.NotNull(viewOrg); + Assert.NotNull(viewOrg.Features); + Assert.Contains("feature-saved-views", viewOrg.Features); + } + [Fact] public async Task DeleteAsync_ExistingOrganization_RemovesOrganization() { diff --git a/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs new file mode 100644 index 0000000000..e751f7e592 --- /dev/null +++ b/tests/Exceptionless.Tests/Controllers/SavedViewControllerTests.cs @@ -0,0 +1,1524 @@ +using Exceptionless.Core.Models; +using Exceptionless.Core.Repositories; +using Exceptionless.Core.Services; +using Exceptionless.Core.Utility; +using Exceptionless.Tests.Extensions; +using Exceptionless.Web.Models; +using FluentRest; +using Foundatio.Repositories; +using Xunit; + +namespace Exceptionless.Tests.Controllers; + +public sealed class SavedViewControllerTests : IntegrationTestsBase +{ + private readonly ISavedViewRepository _savedViewRepository; + private readonly IOrganizationRepository _organizationRepository; + private readonly IUserRepository _userRepository; + private readonly OrganizationService _organizationService; + + public SavedViewControllerTests(ITestOutputHelper output, AppWebHostFactory factory) : base(output, factory) + { + _savedViewRepository = GetService(); + _organizationRepository = GetService(); + _userRepository = GetService(); + _organizationService = GetService(); + } + + protected override async Task ResetDataAsync() + { + await base.ResetDataAsync(); + var service = GetService(); + await service.CreateDataAsync(); + + // Enable saved views feature for all tests in this class + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + if (org is not null) + { + org.Features.Add(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + } + } + + + [Fact] + public async Task PostAsync_NewSavedView_MapsAllPropertiesToSavedView() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Production Errors", + Filter = "status:open", + Time = "[now-7D TO now]", + View = "events", + FilterDefinitions = """[{"type":"keyword","value":"status:open"}]""" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.NotNull(viewFilter.Id); + Assert.Equal(SampleDataService.TEST_ORG_ID, viewFilter.OrganizationId); + Assert.Null(viewFilter.UserId); + Assert.Equal("Production Errors", viewFilter.Name); + Assert.Equal("status:open", viewFilter.Filter); + Assert.Equal("[now-7D TO now]", viewFilter.Time); + Assert.Equal("events", viewFilter.View); + Assert.NotNull(viewFilter.FilterDefinitions); + Assert.Equal(1, viewFilter.Version); + Assert.NotNull(viewFilter.CreatedByUserId); + Assert.Null(viewFilter.UpdatedByUserId); + Assert.True(viewFilter.CreatedUtc > DateTime.MinValue); + Assert.True(viewFilter.UpdatedUtc > DateTime.MinValue); + + // Verify persisted + var savedView = await _savedViewRepository.GetByIdAsync(viewFilter.Id); + Assert.NotNull(savedView); + Assert.Equal("Production Errors", savedView.Name); + } + + [Fact] + public async Task PostAsync_WithIsPrivate_SetsUserIdOnSavedView() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "My Private Filter", + Filter = "status:regressed", + View = "issues" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .QueryString("is_private", "true") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.NotNull(viewFilter.UserId); + } + + [Fact] + public async Task PostAsync_WithoutIsPrivate_DoesNotSetUserId() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization Wide Filter", + Filter = "status:open", + View = "events" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Null(viewFilter.UserId); + } + + [Fact] + public Task PostAsync_WithUnauthorizedOrganization_ReturnsBadRequest() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.FREE_ORG_ID, + Name = "Unauthorized Filter", + Filter = "status:open", + View = "events" + }; + + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeBadRequest() + ); + } + + [Fact] + public async Task PostAsync_AsOrganizationUser_CanCreateSavedView() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization User Filter", + Filter = "type:error", + View = "stream" + }; + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal("Organization User Filter", viewFilter.Name); + } + + + + + + + + [Fact] + public Task PostAsync_WithEmptyName_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "", + Filter = "status:open", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_WithEmptyFilter_ReturnsCreated() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Show All", + Filter = "", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_WithInvalidView_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Invalid View Filter", + Filter = "status:open", + View = "invalid-view" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Theory] + [InlineData("events")] + [InlineData("issues")] + [InlineData("stream")] + public async Task PostAsync_WithValidView_Succeeds(string view) + { + // Arrange & Act + var viewFilter = await CreateSavedViewAsync($"View Test {view}", "status:open", view); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal(view, viewFilter.View); + } + + + + [Fact] + public async Task GetAsync_ExistingFilter_ReturnsFilter() + { + // Arrange + var created = await CreateSavedViewAsync("Get Test Filter", "status:open", "events"); + Assert.NotNull(created); + + // Act + var viewFilter = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal(created.Id, viewFilter.Id); + Assert.Equal(created.Name, viewFilter.Name); + } + + [Fact] + public Task GetAsync_NonExistentFilter_ReturnsNotFound() + { + return SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task GetByOrganizationAsync_ReturnsOrganizationWideAndCurrentUserFilters() + { + // Arrange + var organizationFilter = await CreateSavedViewAsync("Organization Filter", "status:open", "events"); + var privateFilter = await CreateSavedViewAsync("Private Filter", "status:regressed", "events", isPrivate: true); + Assert.NotNull(organizationFilter); + Assert.NotNull(privateFilter); + + // Act + var filters = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(filters); + Assert.True(filters.Count >= 2); + Assert.Contains(filters, f => f.Id == organizationFilter.Id); + Assert.Contains(filters, f => f.Id == privateFilter.Id); + } + + [Fact] + public async Task GetByOrganizationAsync_ExcludesOtherUsersPrivateFilters() + { + // Arrange - Global admin creates a private filter + var privateFilter = await CreateSavedViewAsync("Admin Private", "status:open", "events", isPrivate: true); + Assert.NotNull(privateFilter); + + // Act - Organization user queries the same organization + var filters = await SendRequestAsAsync>(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .StatusCodeShouldBeOk() + ); + + // Assert - should not see the admin's private filter + Assert.NotNull(filters); + Assert.DoesNotContain(filters, f => f.Id == privateFilter.Id); + } + + [Fact] + public async Task GetByViewAsync_ReturnsOnlyMatchingViewFilters() + { + // Arrange + var eventsFilter = await CreateSavedViewAsync("Events Only", "status:open", "events"); + var issuesFilter = await CreateSavedViewAsync("Issues Only", "status:regressed", "issues"); + Assert.NotNull(eventsFilter); + Assert.NotNull(issuesFilter); + + // Act + var filters = await SendRequestAsAsync>(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "events") + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(filters); + Assert.Contains(filters, f => f.Id == eventsFilter.Id); + Assert.DoesNotContain(filters, f => f.Id == issuesFilter.Id); + } + + + + + + [Fact] + public async Task PatchAsync_UpdateName_UpdatesNameAndSetsUpdatedByUserId() + { + // Arrange + var created = await CreateSavedViewAsync("Original Name", "status:open", "events"); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Name = "Updated Name" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("Updated Name", updated.Name); + Assert.NotNull(updated.UpdatedByUserId); + Assert.True(updated.UpdatedUtc >= created.UpdatedUtc); + } + + [Fact] + public async Task PatchAsync_UpdateFilter_UpdatesFilterString() + { + // Arrange + var created = await CreateSavedViewAsync("Filter Update Test", "status:open", "events"); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Filter = "status:regressed" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("status:regressed", updated.Filter); + } + + [Fact] + public async Task PatchAsync_UpdateTime_UpdatesTimeString() + { + // Arrange + var created = await CreateSavedViewAsync("Time Update Test", "status:open", "events"); + Assert.NotNull(created); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Time = "[now-30D TO now]" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("[now-30D TO now]", updated.Time); + } + + [Fact] + public Task PatchAsync_NonExistentFilter_ReturnsNotFound() + { + return SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", "000000000000000000000000") + .Content(new UpdateSavedView { Name = "Nope" }) + .StatusCodeShouldBeNotFound() + ); + } + + + + [Fact] + public async Task DeleteAsync_OwnOrganizationWideFilter_Succeeds() + { + // Arrange + var created = await CreateSavedViewAsync("Delete Me", "status:open", "events"); + Assert.NotNull(created); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeAccepted() + ); + + // Assert + var deleted = await _savedViewRepository.GetByIdAsync(created.Id); + Assert.Null(deleted); + } + + [Fact] + public async Task DeleteAsync_OtherUsersPrivateFilter_ReturnsNotFound() + { + // Arrange - Global admin creates private filter + var privateFilter = await CreateSavedViewAsync("Admin Private Delete", "status:open", "events", isPrivate: true); + Assert.NotNull(privateFilter); + + // Act - Organization user tries to delete it (DenyWithNotFound hides existence) + await SendRequestAsync(r => r + .Delete() + .AsTestOrganizationUser() + .AppendPaths("saved-views", privateFilter.Id) + .StatusCodeShouldBeNotFound() + ); + + // Assert - still exists + var stillExists = await _savedViewRepository.GetByIdAsync(privateFilter.Id); + Assert.NotNull(stillExists); + } + + [Fact] + public async Task DeleteAsync_MultipleFilters_DeletesAll() + { + // Arrange + var first = await CreateSavedViewAsync("Multi Delete 1", "status:open", "events"); + var second = await CreateSavedViewAsync("Multi Delete 2", "status:regressed", "events"); + Assert.NotNull(first); + Assert.NotNull(second); + + // Act + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", $"{first.Id},{second.Id}") + .StatusCodeShouldBeAccepted() + ); + + // Assert + Assert.Null(await _savedViewRepository.GetByIdAsync(first.Id)); + Assert.Null(await _savedViewRepository.GetByIdAsync(second.Id)); + } + + + + [Fact] + public async Task GetAsync_PrivateFilterByOwner_ReturnsFilter() + { + // Arrange + var created = await CreateSavedViewAsync("Owner Access", "status:open", "events", isPrivate: true); + Assert.NotNull(created); + + // Act - same user who created it + var viewFilter = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(viewFilter); + Assert.Equal(created.Id, viewFilter.Id); + } + + [Fact] + public async Task GetAsync_PrivateFilterByOtherUser_ReturnsNotFound() + { + // Arrange - Global admin creates private filter + var created = await CreateSavedViewAsync("Admin Only", "status:open", "events", isPrivate: true); + Assert.NotNull(created); + + // Act - Organization user tries to get it + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task PatchAsync_OrganizationWideFilterByOrganizationMember_Succeeds() + { + // Arrange - Global admin creates organization-wide filter + var created = await CreateSavedViewAsync("Shared Filter", "status:open", "events"); + Assert.NotNull(created); + + // Act - Organization user updates it + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsTestOrganizationUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { Name = "Renamed by Organization User" }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal("Renamed by Organization User", updated.Name); + } + + [Fact] + public Task PostAsync_AnonymousUser_ReturnsUnauthorized() + { + // Arrange + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Anonymous Filter", + Filter = "status:open", + View = "events" + }; + + // Act & Assert + return SendRequestAsync(r => r + .Post() + .AsAnonymousUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnauthorized() + ); + } + + + + [Fact] + public async Task PostAsync_ExceedsPerOrgCap_ReturnsBadRequest() + { + // Arrange - Directly seed repository to approach the cap + var filters = new List(); + for (int i = 0; i < 100; i++) + { + filters.Add(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = $"Cap Test {i}", + Filter = "status:open", + View = "events", + Version = 1, + CreatedByUserId = "537650f3b77efe23a47914f0", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }); + } + await _savedViewRepository.AddAsync(filters, o => o.ImmediateConsistency()); + + // Act - try to add one more + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "One Too Many", + Filter = "status:open", + View = "events" + }; + + await SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeBadRequest() + ); + } + + + // Feature flag tests + + [Fact] + public async Task PostAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + { + // Arrange — disable the saved views feature + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + org.Features.Remove(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Blocked View", + Filter = "status:open", + View = "events" + }; + + await SendRequestAsync(r => r + .Post() + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PatchAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + { + // Arrange — create a view directly (bypassing feature check), then disable feature + var savedView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Existing View", + Filter = "status:open", + View = "events", + Version = 1, + CreatedByUserId = "537650f3b77efe23a47914f0", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }, o => o.ImmediateConsistency()); + + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + org.Features.Remove(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", savedView.Id) + .Content(new UpdateSavedView { Name = "Updated Name" }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task DeleteAsync_WhenFeatureDisabled_ReturnsUnprocessableEntity() + { + // Arrange — create a view directly, then disable feature + var savedView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "View To Delete", + Filter = "status:open", + View = "events", + Version = 1, + CreatedByUserId = "537650f3b77efe23a47914f0", + CreatedUtc = DateTime.UtcNow, + UpdatedUtc = DateTime.UtcNow + }, o => o.ImmediateConsistency()); + + var org = await _organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(org); + org.Features.Remove(OrganizationFeatures.SavedViews); + await _organizationRepository.SaveAsync(org, o => o.ImmediateConsistency()); + + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", savedView.Id) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + + // Cleanup tests + + [Fact] + public async Task RemoveUser_DeletesPrivateSavedViews_ButPreservesOrganizationWideViews() + { + // Arrange — create an organization-wide view and a private view for the test organization user + var orgUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(orgUser); + + var orgWideView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization Wide", + Filter = "status:open", + View = "events", + CreatedByUserId = orgUser.Id + }); + + var privateView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + UserId = orgUser.Id, + Name = "My Private View", + Filter = "type:error", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await RefreshDataAsync(); + + // Act — remove the user from the organization via the API + await SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "users", SampleDataService.TEST_ORG_USER_EMAIL) + .StatusCodeShouldBeOk() + ); + + await RefreshDataAsync(); + + // Assert — private view is gone, organization-wide view remains + Assert.Null(await _savedViewRepository.GetByIdAsync(privateView.Id)); + Assert.NotNull(await _savedViewRepository.GetByIdAsync(orgWideView.Id)); + } + + [Fact] + public async Task SoftDeleteOrganization_RemovesAllSavedViews() + { + // Arrange + var orgUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_USER_EMAIL); + Assert.NotNull(orgUser); + + await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization View", + Filter = "status:open", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + UserId = orgUser.Id, + Name = "Private View", + Filter = "type:error", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await RefreshDataAsync(); + + var countBefore = await _savedViewRepository.CountByOrganizationIdAsync(SampleDataService.TEST_ORG_ID); + Assert.True(countBefore >= 2); + + // Act + var organizationRepository = GetService(); + var organization = await organizationRepository.GetByIdAsync(SampleDataService.TEST_ORG_ID); + Assert.NotNull(organization); + await _organizationService.SoftDeleteOrganizationAsync(organization, orgUser.Id); + await RefreshDataAsync(); + + // Assert + var countAfter = await _savedViewRepository.CountByOrganizationIdAsync(SampleDataService.TEST_ORG_ID); + Assert.Equal(0, countAfter); + } + + [Fact] + public async Task RemoveUserSavedViews_OnlyDeletesPrivateViews() + { + // Arrange + var orgUser = await _userRepository.GetByEmailAddressAsync(SampleDataService.TEST_ORG_USER_EMAIL); + Assert.NotNull(orgUser); + + var orgWide = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Organization Wide", + Filter = "status:open", + View = "events", + CreatedByUserId = orgUser.Id + }); + + var privateView = await _savedViewRepository.AddAsync(new SavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + UserId = orgUser.Id, + Name = "Private", + Filter = "type:error", + View = "events", + CreatedByUserId = orgUser.Id + }); + + await RefreshDataAsync(); + + // Act + var removed = await _organizationService.RemoveUserSavedViewsAsync(SampleDataService.TEST_ORG_ID, orgUser.Id); + await RefreshDataAsync(); + + // Assert + Assert.Equal(1, removed); + Assert.Null(await _savedViewRepository.GetByIdAsync(privateView.Id)); + Assert.NotNull(await _savedViewRepository.GetByIdAsync(orgWide.Id)); + } + + private async Task CreateSavedViewAsync(string name, string filter, string view, bool isPrivate = false, bool isDefault = false) + { + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = name, + Filter = filter, + View = view, + IsDefault = isDefault + }; + + var result = await SendRequestAsAsync(r => + { + r.Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newView) + .StatusCodeShouldBeCreated(); + + if (isPrivate) + { + r.QueryString("is_private", "true"); + } + }); + + await RefreshDataAsync(); + return result; + } + + // IsDefault tests + + [Fact] + public async Task PostAsync_WithIsDefault_SetsIsDefaultOnSavedView() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Default Events", "status:open", "events", isDefault: true); + + // Assert + Assert.NotNull(created); + Assert.True(created.IsDefault); + } + + [Fact] + public async Task PostAsync_WithoutIsDefault_DefaultsToFalse() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Not Default", "status:open", "events"); + + // Assert + Assert.NotNull(created); + Assert.False(created.IsDefault); + } + + [Fact] + public async Task PostAsync_NewDefault_ClearsPreviousDefault() + { + // Arrange + var first = await CreateSavedViewAsync("First Default", "status:open", "events", isDefault: true); + Assert.NotNull(first); + Assert.True(first.IsDefault); + + // Act - create another default for same view + var second = await CreateSavedViewAsync("Second Default", "status:regressed", "events", isDefault: true); + Assert.NotNull(second); + Assert.True(second.IsDefault); + + // Assert - first should no longer be default + var firstReloaded = await _savedViewRepository.GetByIdAsync(first.Id); + Assert.NotNull(firstReloaded); + Assert.False(firstReloaded.IsDefault); + } + + [Fact] + public async Task PostAsync_NewDefaultForDifferentView_DoesNotClearOtherViewDefault() + { + // Arrange + var eventsDefault = await CreateSavedViewAsync("Events Default", "status:open", "events", isDefault: true); + Assert.NotNull(eventsDefault); + + // Act - create default for issues view + var issuesDefault = await CreateSavedViewAsync("Issues Default", "status:regressed", "issues", isDefault: true); + Assert.NotNull(issuesDefault); + + // Assert - events default should be unaffected + var eventsReloaded = await _savedViewRepository.GetByIdAsync(eventsDefault.Id); + Assert.NotNull(eventsReloaded); + Assert.True(eventsReloaded.IsDefault); + } + + [Fact] + public async Task PatchAsync_SetIsDefault_ClearsPreviousDefault() + { + // Arrange + var first = await CreateSavedViewAsync("First", "status:open", "events", isDefault: true); + var second = await CreateSavedViewAsync("Second", "status:regressed", "events"); + Assert.NotNull(first); + Assert.NotNull(second); + + // Act - set second as default via PATCH + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", second.Id) + .Content(new UpdateSavedView { IsDefault = true }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.True(updated.IsDefault); + + var firstReloaded = await _savedViewRepository.GetByIdAsync(first.Id); + Assert.NotNull(firstReloaded); + Assert.False(firstReloaded.IsDefault); + } + + [Fact] + public async Task PatchAsync_UnsetIsDefault_RemovesDefault() + { + // Arrange + var created = await CreateSavedViewAsync("Default View", "status:open", "events", isDefault: true); + Assert.NotNull(created); + Assert.True(created.IsDefault); + + // Act + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { IsDefault = false }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.False(updated.IsDefault); + } + + // CanUpdateAsync permission tests + + [Fact] + public async Task PatchAsync_OtherUsersPrivateView_ReturnsNotFound() + { + // Arrange - Global admin creates private view + var privateView = await CreateSavedViewAsync("Admin Private", "status:open", "events", isPrivate: true); + Assert.NotNull(privateView); + + // Act - Organization user tries to update it + await SendRequestAsync(r => r + .Patch() + .AsTestOrganizationUser() + .AppendPaths("saved-views", privateView.Id) + .Content(new UpdateSavedView { Name = "Hacked" }) + .StatusCodeShouldBeNotFound() + ); + + // Assert - name unchanged + var unchanged = await _savedViewRepository.GetByIdAsync(privateView.Id); + Assert.NotNull(unchanged); + Assert.Equal("Admin Private", unchanged.Name); + } + + [Fact] + public async Task PatchAsync_UpdateFilterDefinitions_PersistsJsonBlob() + { + // Arrange + var created = await CreateSavedViewAsync("FilterDef Test", "status:open", "events"); + Assert.NotNull(created); + + // Act + const string filterDefs = """[{"type":"keyword","value":"status:open"},{"type":"boolean","term":"is_first_occurrence","value":true}]"""; + var updated = await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView { FilterDefinitions = filterDefs }) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(updated); + Assert.Equal(filterDefs, updated.FilterDefinitions); + } + + [Fact] + public Task GetByOrganizationAsync_WithUnauthorizedOrg_ReturnsNotFound() + { + // Act & Assert + return SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", "000000000000000000000000", "saved-views") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task DeleteAsync_NonExistentView_ReturnsNotFound() + { + return SendRequestAsync(r => r + .Delete() + .AsGlobalAdminUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task PostAsync_IsDefaultResponse_IncludesIsDefaultField() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Check Response", "status:open", "events", isDefault: true); + Assert.NotNull(created); + + // Act - fetch back via GET + var fetched = await SendRequestAsAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .StatusCodeShouldBeOk() + ); + + // Assert + Assert.NotNull(fetched); + Assert.True(fetched.IsDefault); + } + + // Security tests + + [Fact] + public Task PostAsync_WithXssInName_StoresLiterally() + { + // XSS in the name should be stored as-is; escaping is the frontend's job + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "", + Filter = "status:open", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_FilterExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Long Filter", + Filter = new string('x', 2001), + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_FilterDefinitionsExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Long FilterDefs", + Filter = "status:open", + View = "events", + FilterDefinitions = new string('x', 10001) + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_TimeExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Long Time", + Filter = "status:open", + View = "events", + Time = new string('t', 101) + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_NameExceedsMaxLength_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = new string('n', 101), + Filter = "status:open", + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task GetByViewAsync_WithInvalidView_ReturnsNotFound() + { + return SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views", "dashboard") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public async Task GetByOrganizationAsync_CrossOrganization_ReturnsNotFound() + { + // Arrange - Create a filter in TEST_ORG + await CreateSavedViewAsync("Cross Organization Test", "status:open", "events"); + + // Act - Try to list from FREE_ORG (wrong org for this user) + await SendRequestAsync(r => r + .AsTestOrganizationUser() + .AppendPaths("organizations", SampleDataService.FREE_ORG_ID, "saved-views") + .StatusCodeShouldBeNotFound() + ); + } + + [Fact] + public Task GetAsync_AnonymousUser_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .AsAnonymousUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeUnauthorized() + ); + } + + [Fact] + public Task PatchAsync_AnonymousUser_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .Patch() + .AsAnonymousUser() + .AppendPaths("saved-views", "000000000000000000000000") + .Content(new UpdateSavedView { Name = "Hacked" }) + .StatusCodeShouldBeUnauthorized() + ); + } + + [Fact] + public Task DeleteAsync_AnonymousUser_ReturnsUnauthorized() + { + return SendRequestAsync(r => r + .Delete() + .AsAnonymousUser() + .AppendPaths("saved-views", "000000000000000000000000") + .StatusCodeShouldBeUnauthorized() + ); + } + + [Fact] + public Task PostAsync_FilterAtMaxLength_Succeeds() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Max Length Filter", + Filter = new string('x', 2000), + View = "events" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_InvalidJsonFilterDefinitions_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Bad JSON", + Filter = "status:open", + View = "events", + FilterDefinitions = "not valid json" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_JsonObjectFilterDefinitions_ReturnsUnprocessableEntity() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "JSON Object", + Filter = "status:open", + View = "events", + FilterDefinitions = """{"type":"keyword","value":"test"}""" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_ValidJsonArrayFilterDefinitions_Succeeds() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Valid JSON Array", + Filter = "status:open", + View = "events", + FilterDefinitions = """[{"type":"keyword","value":"test"}]""" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + [Fact] + public Task PostAsync_EmptyArrayFilterDefinitions_Succeeds() + { + var newFilter = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Empty Array", + Filter = "status:open", + View = "events", + FilterDefinitions = "[]" + }; + + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(newFilter) + .StatusCodeShouldBeCreated() + ); + } + + // Private views cannot be default tests + + [Fact] + public Task PostAsync_PrivateAndDefault_ReturnsUnprocessableEntity() + { + // Arrange + var newView = new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Private Default", + Filter = "status:open", + View = "events", + IsDefault = true + }; + + // Act & Assert - private + default should be rejected with 422 + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .QueryString("is_private", "true") + .Content(newView) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PostAsync_PrivateWithoutDefault_Succeeds() + { + // Arrange & Act + var created = await CreateSavedViewAsync("Private Non-Default", "status:open", "events", isPrivate: true); + + // Assert + Assert.NotNull(created); + Assert.False(created.IsDefault); + Assert.NotNull(created.UserId); + } + + [Fact] + public async Task PatchAsync_PrivateViewSetDefault_ReturnsUnprocessableEntity() + { + // Arrange - create a private view + var privateView = await CreateSavedViewAsync("Private View", "status:open", "events", isPrivate: true); + Assert.NotNull(privateView); + Assert.NotNull(privateView.UserId); + + // Act & Assert - trying to set a private view as default should fail with 422 + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", privateView.Id) + .Content(new UpdateSavedView { IsDefault = true }) + .StatusCodeShouldBeUnprocessableEntity() + ); + + // Verify it's still not default + var reloaded = await _savedViewRepository.GetByIdAsync(privateView.Id); + Assert.NotNull(reloaded); + Assert.False(reloaded.IsDefault); + } + + [Fact] + public async Task PostAsync_OrganizationWideDefault_DoesNotAffectPrivateViews() + { + // Arrange - create a private view (not default) + var privateView = await CreateSavedViewAsync("My Private", "status:open", "events", isPrivate: true); + Assert.NotNull(privateView); + + // Act - create an organization-wide default + var organizationDefault = await CreateSavedViewAsync("Organization Default", "status:regressed", "events", isDefault: true); + Assert.NotNull(organizationDefault); + Assert.True(organizationDefault.IsDefault); + + // Assert - private view should be unaffected + var privateReloaded = await _savedViewRepository.GetByIdAsync(privateView.Id); + Assert.NotNull(privateReloaded); + Assert.False(privateReloaded.IsDefault); + } + + [Fact] + public async Task PostAsync_DefaultForDifferentViews_AreIndependent() + { + // Arrange & Act - create defaults for different views + var eventsDefault = await CreateSavedViewAsync("Events Default", "status:open", "events", isDefault: true); + var issuesDefault = await CreateSavedViewAsync("Issues Default", "status:regressed", "issues", isDefault: true); + var streamDefault = await CreateSavedViewAsync("Stream Default", "type:error", "stream", isDefault: true); + + // Assert - all should be independently default + Assert.NotNull(eventsDefault); + Assert.NotNull(issuesDefault); + Assert.NotNull(streamDefault); + Assert.True(eventsDefault.IsDefault); + Assert.True(issuesDefault.IsDefault); + Assert.True(streamDefault.IsDefault); + + // Verify by reloading + var eventsReloaded = await _savedViewRepository.GetByIdAsync(eventsDefault.Id); + var issuesReloaded = await _savedViewRepository.GetByIdAsync(issuesDefault.Id); + var streamReloaded = await _savedViewRepository.GetByIdAsync(streamDefault.Id); + Assert.True(eventsReloaded!.IsDefault); + Assert.True(issuesReloaded!.IsDefault); + Assert.True(streamReloaded!.IsDefault); + } + + [Fact] + public async Task PatchAsync_UnsetDefault_OnlyAffectsTargetView() + { + // Arrange - set defaults for two views + var eventsDefault = await CreateSavedViewAsync("Events Default", "status:open", "events", isDefault: true); + var issuesDefault = await CreateSavedViewAsync("Issues Default", "status:regressed", "issues", isDefault: true); + Assert.NotNull(eventsDefault); + Assert.NotNull(issuesDefault); + + // Act - unset events default + await SendRequestAsAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", eventsDefault.Id) + .Content(new UpdateSavedView { IsDefault = false }) + .StatusCodeShouldBeOk() + ); + + // Assert - issues default should be unaffected + var eventsReloaded = await _savedViewRepository.GetByIdAsync(eventsDefault.Id); + var issuesReloaded = await _savedViewRepository.GetByIdAsync(issuesDefault.Id); + Assert.False(eventsReloaded!.IsDefault); + Assert.True(issuesReloaded!.IsDefault); + } + + [Fact] + public Task PostAsync_InvalidColumnKey_ReturnsUnprocessableEntity() + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Bad Columns", + View = "events", + Columns = new Dictionary { ["status"] = true } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public async Task PatchAsync_InvalidColumnKey_ReturnsUnprocessableEntity() + { + // Arrange + var created = await CreateSavedViewAsync("Patch Column Test", "status:open", "events"); + Assert.NotNull(created); + + // Act & Assert + await SendRequestAsync(r => r + .Patch() + .AsGlobalAdminUser() + .AppendPaths("saved-views", created.Id) + .Content(new UpdateSavedView + { + Columns = new Dictionary { ["INVALID_COLUMN"] = true } + }) + .StatusCodeShouldBeUnprocessableEntity() + ); + } + + [Fact] + public Task PostAsync_ValidColumnKeys_Succeeds() + { + // Arrange & Act & Assert + return SendRequestAsync(r => r + .Post() + .AsGlobalAdminUser() + .AppendPaths("organizations", SampleDataService.TEST_ORG_ID, "saved-views") + .Content(new NewSavedView + { + OrganizationId = SampleDataService.TEST_ORG_ID, + Name = "Valid Columns", + View = "events", + Columns = new Dictionary { ["user"] = true, ["date"] = false } + }) + .StatusCodeShouldBeCreated() + ); + } + +} diff --git a/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs new file mode 100644 index 0000000000..e66fcadc2b --- /dev/null +++ b/tests/Exceptionless.Tests/Mapping/SavedViewMapperTests.cs @@ -0,0 +1,198 @@ +using Exceptionless.Core.Models; +using Exceptionless.Web.Mapping; +using Exceptionless.Web.Models; +using Xunit; + +namespace Exceptionless.Tests.Mapping; + +public sealed class SavedViewMapperTests +{ + private readonly SavedViewMapper _mapper; + + public SavedViewMapperTests() + { + _mapper = new SavedViewMapper(); + } + + [Fact] + public void MapToSavedView_WithValidNewSavedView_MapsAllProperties() + { + // Arrange + var source = new NewSavedView + { + OrganizationId = "537650f3b77efe23a47914f3", + Name = "Open Issues", + Filter = "(status:open OR status:regressed)", + Time = "[now-7d TO now]", + View = "issues", + FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", + Columns = new Dictionary { ["status"] = true, ["users"] = false }, + IsDefault = true + }; + + // Act + var result = _mapper.MapToSavedView(source); + + // Assert + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("Open Issues", result.Name); + Assert.Equal("(status:open OR status:regressed)", result.Filter); + Assert.Equal("[now-7d TO now]", result.Time); + Assert.Equal("issues", result.View); + Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\",\"regressed\"]}]", result.FilterDefinitions); + Assert.NotNull(result.Columns); + Assert.True(result.Columns["status"]); + Assert.False(result.Columns["users"]); + Assert.True(result.IsDefault); + } + + [Fact] + public void MapToSavedView_IgnoredFields_AreNotMapped() + { + // Arrange + var source = new NewSavedView + { + OrganizationId = "537650f3b77efe23a47914f3", + Name = "Test View", + View = "events" + }; + + // Act + var result = _mapper.MapToSavedView(source); + + // Assert - Version, CreatedByUserId, UpdatedByUserId, UserId are ignored by mapper + // Version keeps its C# record initializer default of 1 + Assert.Equal(1, result.Version); + Assert.Null(result.CreatedByUserId); + Assert.Null(result.UpdatedByUserId); + Assert.Null(result.UserId); + } + + [Fact] + public void MapToSavedView_WithNullOptionalFields_MapsNulls() + { + // Arrange + var source = new NewSavedView + { + OrganizationId = "537650f3b77efe23a47914f3", + Name = "Minimal View", + View = "stream" + }; + + // Act + var result = _mapper.MapToSavedView(source); + + // Assert + Assert.Null(result.Filter); + Assert.Null(result.Time); + Assert.Null(result.FilterDefinitions); + Assert.Null(result.Columns); + Assert.False(result.IsDefault); + } + + [Fact] + public void MapToViewSavedView_WithValidSavedView_MapsAllProperties() + { + // Arrange + var now = DateTime.UtcNow; + var source = new SavedView + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + UserId = "1ecd0826e447ad1e78822555", + CreatedByUserId = "1ecd0826e447ad1e78822555", + UpdatedByUserId = "1ecd0826e447ad1e78822666", + Filter = "status:open", + FilterDefinitions = "[{\"type\":\"status\",\"values\":[\"open\"]}]", + Columns = new Dictionary { ["status"] = true }, + IsDefault = false, + Name = "My View", + Time = "[now-30d TO now]", + Version = 1, + View = "issues", + CreatedUtc = now.AddDays(-1), + UpdatedUtc = now + }; + + // Act + var result = _mapper.MapToViewSavedView(source); + + // Assert + Assert.Equal("88cd0826e447a44e78877ab1", result.Id); + Assert.Equal("537650f3b77efe23a47914f3", result.OrganizationId); + Assert.Equal("1ecd0826e447ad1e78822555", result.UserId); + Assert.Equal("1ecd0826e447ad1e78822555", result.CreatedByUserId); + Assert.Equal("1ecd0826e447ad1e78822666", result.UpdatedByUserId); + Assert.Equal("status:open", result.Filter); + Assert.Equal("[{\"type\":\"status\",\"values\":[\"open\"]}]", result.FilterDefinitions); + Assert.NotNull(result.Columns); + Assert.True(result.Columns["status"]); + Assert.False(result.IsDefault); + Assert.Equal("My View", result.Name); + Assert.Equal("[now-30d TO now]", result.Time); + Assert.Equal(1, result.Version); + Assert.Equal("issues", result.View); + Assert.Equal(now.AddDays(-1), result.CreatedUtc); + Assert.Equal(now, result.UpdatedUtc); + } + + [Fact] + public void MapToViewSavedView_WithNullOptionalFields_MapsNulls() + { + // Arrange + var source = new SavedView + { + Id = "88cd0826e447a44e78877ab1", + OrganizationId = "537650f3b77efe23a47914f3", + CreatedByUserId = "1ecd0826e447ad1e78822555", + Name = "Organization Wide View", + View = "events", + Version = 1 + }; + + // Act + var result = _mapper.MapToViewSavedView(source); + + // Assert + Assert.Null(result.UserId); + Assert.Null(result.UpdatedByUserId); + Assert.Null(result.Filter); + Assert.Null(result.FilterDefinitions); + Assert.Null(result.Columns); + Assert.Null(result.Time); + } + + [Fact] + public void MapToViewSavedViews_WithMultipleSavedViews_MapsAll() + { + // Arrange + var views = new List + { + new() { Id = "88cd0826e447a44e78877ab1", OrganizationId = "537650f3b77efe23a47914f3", CreatedByUserId = "1ecd0826e447ad1e78822555", Name = "View 1", View = "events" }, + new() { Id = "88cd0826e447a44e78877ab2", OrganizationId = "537650f3b77efe23a47914f3", CreatedByUserId = "1ecd0826e447ad1e78822555", Name = "View 2", View = "issues" }, + new() { Id = "88cd0826e447a44e78877ab3", OrganizationId = "537650f3b77efe23a47914f3", CreatedByUserId = "1ecd0826e447ad1e78822555", Name = "View 3", View = "stream" } + }; + + // Act + var result = _mapper.MapToViewSavedViews(views); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("88cd0826e447a44e78877ab1", result[0].Id); + Assert.Equal("88cd0826e447a44e78877ab2", result[1].Id); + Assert.Equal("88cd0826e447a44e78877ab3", result[2].Id); + } + + [Fact] + public void MapToViewSavedViews_WithEmptyList_ReturnsEmptyList() + { + // Arrange + var views = new List(); + + // Act + var result = _mapper.MapToViewSavedViews(views); + + // Assert + Assert.Empty(result); + } +} diff --git a/tests/http/saved-views.http b/tests/http/saved-views.http new file mode 100644 index 0000000000..553b3a3e56 --- /dev/null +++ b/tests/http/saved-views.http @@ -0,0 +1,93 @@ +@apiUrl = http://localhost:5200/api/v2 +@email = test@localhost +@password = tester +@organizationId = 537650f3b77efe23a47914f3 +@feature = feature-saved-views + +### login to test account +# @name login +POST {{apiUrl}}/auth/login +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}" +} + +### + +@token = {{login.response.body.$.token}} + +### Enable saved views feature flag +POST {{apiUrl}}/organizations/{{organizationId}}/features/{{feature}} +Authorization: Bearer {{token}} + +### Get saved views by organization +GET {{apiUrl}}/organizations/{{organizationId}}/saved-views +Authorization: Bearer {{token}} + +### Get saved views by view +GET {{apiUrl}}/organizations/{{organizationId}}/saved-views/events +Authorization: Bearer {{token}} + +### Create organization-wide saved view +# @name newSavedView +POST {{apiUrl}}/organizations/{{organizationId}}/saved-views +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "organization_id": "{{organizationId}}", + "name": "Open Events", + "filter": "status:open", + "time": "[now-7d TO now]", + "view": "events", + "columns": { + "user": true, + "date": true + }, + "is_default": false +} + +### + +@savedViewId = {{newSavedView.response.body.$.id}} + +### Create private saved view +POST {{apiUrl}}/organizations/{{organizationId}}/saved-views?is_private=true +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "organization_id": "{{organizationId}}", + "name": "My Private View", + "filter": "type:error", + "view": "stream", + "columns": { + "user": true, + "date": true + }, + "is_default": false +} + +### Get saved view by id +GET {{apiUrl}}/saved-views/{{savedViewId}} +Authorization: Bearer {{token}} + +### Update saved view +PATCH {{apiUrl}}/saved-views/{{savedViewId}} +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "name": "Open Events (Last 30d)", + "time": "[now-30d TO now]" +} + +### Delete saved view +DELETE {{apiUrl}}/saved-views/{{savedViewId}} +Authorization: Bearer {{token}} + +### Disable saved views feature flag +DELETE {{apiUrl}}/organizations/{{organizationId}}/features/{{feature}} +Authorization: Bearer {{token}}