Skip to content

Commit 0a8855a

Browse files
committed
feat: return 426 for premium filter searches on free plans + tag saved views
- EventController and StackController return 426 when all organizations in scope are on free plans and the filter references premium features (idx.*) - SavedView.UsesPremiumFeatures is computed at save/update time via PersistentEventQueryValidator and exposed in ViewSavedView - Frontend shows upgrade dialog on 426 search responses and badges saved views that require a paid plan in the sidebar
1 parent a5fc648 commit 0a8855a

13 files changed

Lines changed: 119 additions & 7 deletions

File tree

src/Exceptionless.Core/Models/SavedView.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ public record SavedView : IOwnedByOrganizationWithIdentity, IHaveDates
7777
/// <summary>Schema version for future filter definition migrations.</summary>
7878
public int Version { get; set; } = 1;
7979

80+
/// <summary>True when the filter references at least one custom field or other premium feature.</summary>
81+
public bool UsesPremiumFeatures { get; set; }
82+
8083
/// <summary>Dashboard page identifier: "events", "issues", or "stream".</summary>
8184
[Required]
8285
[RegularExpression("^(events|issues|stream)$")]

src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,7 @@ export interface ViewSavedView {
582582
sort?: null | string;
583583
/** @format int32 */
584584
version: number;
585+
uses_premium_features: boolean;
585586
view_type: string;
586587
/** @format date-time */
587588
created_utc: string;

src/Exceptionless.Web/ClientApp/src/lib/generated/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -638,6 +638,7 @@ export const ViewSavedViewSchema = object({
638638
time: string().min(1, "Time is required").nullable().optional(),
639639
sort: string().min(1, "Sort is required").nullable().optional(),
640640
version: int32(),
641+
uses_premium_features: boolean(),
641642
view_type: string().min(1, "View type is required"),
642643
created_utc: iso.datetime(),
643644
updated_utc: iso.datetime(),

src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar.svelte

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import ChevronRight from '@lucide/svelte/icons/chevron-right';
1212
import Settings from '@lucide/svelte/icons/settings-2';
1313
import Wrench from '@lucide/svelte/icons/wrench';
14+
import Zap from '@lucide/svelte/icons/zap';
1415
import { onDestroy } from 'svelte';
1516
1617
import type { NavigationItem } from '../../../routes.svelte';
@@ -254,10 +255,13 @@
254255
</DropdownMenu.Item>
255256
<DropdownMenu.Separator />
256257
{/if}
257-
{#each route.children as savedItem (savedItem.href)}
258+
{#each route.children as savedItem (savedItem.href)}
258259
<DropdownMenu.Item>
259260
<A variant="ghost" href={savedItem.href} class="w-full" onclick={onFlyoutLinkClick}>
260261
{savedItem.title}
262+
{#if savedItem.usesPremiumFeatures}
263+
<Zap class="text-muted-foreground ml-1 inline-block size-3" aria-label="Requires premium plan" title="This view uses premium features" />
264+
{/if}
261265
</A>
262266
</DropdownMenu.Item>
263267
{/each}
@@ -308,6 +312,9 @@
308312
{...subProps}
309313
>
310314
<span class="truncate">{savedItem.title}</span>
315+
{#if savedItem.usesPremiumFeatures}
316+
<Zap class="text-muted-foreground ml-auto size-3 shrink-0" aria-label="Requires premium plan" title="This view uses premium features" />
317+
{/if}
311318
</A>
312319
{/snippet}
313320
</Sidebar.MenuSubButton>

src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,8 @@
421421
const children = [
422422
...sortedViews.map((savedView) => ({
423423
href: buildSavedViewHref(savedView),
424-
title: savedView.name
424+
title: savedView.name,
425+
usesPremiumFeatures: savedView.uses_premium_features
425426
})),
426427
...(route.children ?? [])
427428
];

src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { routes as authRoutes } from './(auth)/routes.svelte';
1010
export type NavigationChild = {
1111
href: string;
1212
title: string;
13+
usesPremiumFeatures?: boolean;
1314
};
1415

1516
export type NavigationItem = {

src/Exceptionless.Web/Controllers/EventController.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,11 @@ private async Task<ActionResult<CountResult>> CountInternalAsync(AppFilter sf, T
239239

240240
sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || far.UsesPremiumFeatures;
241241

242+
if (sf.UsesPremiumFeatures && sf.Organizations.Count > 0 && sf.Organizations.All(o => !o.HasPremiumFeatures))
243+
return Problem(
244+
detail: "Searching with custom fields requires a paid plan. Please upgrade to use this filter.",
245+
statusCode: StatusCodes.Status426UpgradeRequired);
246+
242247
if (mode == "stack_new")
243248
filter = AddFirstOccurrenceFilter(ti.Range, filter);
244249

@@ -295,6 +300,11 @@ private async Task<ActionResult<ICollection<PersistentEvent>>> GetInternalAsync(
295300

296301
sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures;
297302

303+
if (sf.UsesPremiumFeatures && sf.Organizations.Count > 0 && sf.Organizations.All(o => !o.HasPremiumFeatures))
304+
return Problem(
305+
detail: "Searching with custom fields requires a paid plan. Please upgrade to use this filter.",
306+
statusCode: StatusCodes.Status426UpgradeRequired);
307+
298308
try
299309
{
300310
FindResults<PersistentEvent> events;

src/Exceptionless.Web/Controllers/SavedViewController.cs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,18 @@ namespace Exceptionless.App.Controllers.API;
2020
public class SavedViewController : RepositoryApiController<ISavedViewRepository, SavedView, ViewSavedView, NewSavedView, UpdateSavedView>
2121
{
2222
private const int MaxViewsPerOrganization = 100;
23+
private readonly PersistentEventQueryValidator _eventQueryValidator;
2324

2425
public SavedViewController(
2526
ISavedViewRepository repository,
2627
ApiMapper mapper,
2728
IAppQueryValidator validator,
29+
PersistentEventQueryValidator eventQueryValidator,
2830
TimeProvider timeProvider,
2931
ILoggerFactory loggerFactory) : base(repository, mapper, validator, timeProvider, loggerFactory)
30-
{ }
32+
{
33+
_eventQueryValidator = eventQueryValidator;
34+
}
3135

3236
protected override SavedView MapToModel(NewSavedView newModel)
3337
{
@@ -309,15 +313,18 @@ protected override async Task<PermissionResult> CanUpdateAsync(SavedView origina
309313
.FirstOrDefault();
310314
}
311315

312-
protected override Task<SavedView> AddModelAsync(SavedView value)
316+
protected override async Task<SavedView> AddModelAsync(SavedView value)
313317
{
314318
value.CreatedByUserId = CurrentUser.Id;
315319
value.Version = 1;
316320

317-
return base.AddModelAsync(value);
321+
var pr = await _eventQueryValidator.ValidateQueryAsync(value.Filter);
322+
value.UsesPremiumFeatures = pr.UsesPremiumFeatures;
323+
324+
return await base.AddModelAsync(value);
318325
}
319326

320-
protected override Task<SavedView> UpdateModelAsync(SavedView original, Delta<UpdateSavedView> changes)
327+
protected override async Task<SavedView> UpdateModelAsync(SavedView original, Delta<UpdateSavedView> changes)
321328
{
322329
var changedNames = changes.GetChangedPropertyNames();
323330
changes.Patch(original);
@@ -330,7 +337,13 @@ protected override Task<SavedView> UpdateModelAsync(SavedView original, Delta<Up
330337

331338
original.UpdatedByUserId = CurrentUser.Id;
332339

333-
return _repository.SaveAsync(original, o => o.Cache());
340+
if (changedNames.Contains(nameof(UpdateSavedView.Filter)))
341+
{
342+
var pr = await _eventQueryValidator.ValidateQueryAsync(original.Filter);
343+
original.UsesPremiumFeatures = pr.UsesPremiumFeatures;
344+
}
345+
346+
return await _repository.SaveAsync(original, o => o.Cache());
334347
}
335348

336349
protected override async Task<PermissionResult> CanDeleteAsync(SavedView value)

src/Exceptionless.Web/Controllers/StackController.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,11 @@ private async Task<ActionResult<IReadOnlyCollection<Stack>>> GetInternalAsync(Ap
490490

491491
sf.UsesPremiumFeatures = pr.UsesPremiumFeatures;
492492

493+
if (sf.UsesPremiumFeatures && sf.Organizations.Count > 0 && sf.Organizations.All(o => !o.HasPremiumFeatures))
494+
return Problem(
495+
detail: "Searching with custom fields requires a paid plan. Please upgrade to use this filter.",
496+
statusCode: StatusCodes.Status426UpgradeRequired);
497+
493498
try
494499
{
495500
var results = await _repository.FindAsync(q => q.AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null).FilterExpression(filter).SortExpression(sort).DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field), o => o.PageNumber(page).PageLimit(limit));

src/Exceptionless.Web/Models/SavedView/ViewSavedView.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public record ViewSavedView : IIdentity, IHaveDates
3131
public string? Time { get; set; }
3232
public string? Sort { get; set; }
3333
public int Version { get; set; }
34+
public bool UsesPremiumFeatures { get; set; }
3435
public string ViewType { get; set; } = null!;
3536

3637
public DateTime CreatedUtc { get; set; }

0 commit comments

Comments
 (0)