From 139ee30209625a14441af71cc230a1e9fcfa06fa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:31:19 -0500 Subject: [PATCH 01/39] Adds Chrome MCP verification instruction Adds instruction to always use the chrome mcp to verify visual and functional correctness. This ensures consistent testing and validation, particularly with the new site /next path. --- .github/instructions/frontend.instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 228f6aeb2..e6c4f79ba 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -14,6 +14,7 @@ Located in the `src/Exceptionless.Web/ClientApp` directory. - When there is a linting error, always try to run `npm run format` first. - Limit use of $effect as there is usually a better way to solve the problem like using $derived. - **Do NOT** use any server-side Svelte features. +- Always use the chrome mcp to verify visual and functional correctness, default to using the new site /next path. ## Architecture & Components From d392f2a5a22284fe59bb215d689d0dfd34d35c8c Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:31:35 -0500 Subject: [PATCH 02/39] Adds separator above project data table Adds a separator line above the project data table for better visual separation of the header and content. This improves the overall layout and readability of the projects page. --- .../(app)/organization/[organizationId]/projects/+page.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/projects/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/projects/+page.svelte index 8dffd0451..ca3b257d8 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/projects/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/[organizationId]/projects/+page.svelte @@ -7,6 +7,7 @@ import { H3, Muted } from '$comp/typography'; import { Button } from '$comp/ui/button'; import { Input } from '$comp/ui/input'; + import { Separator } from '$comp/ui/separator'; import { organization } from '$features/organizations/context.svelte'; import { type GetOrganizationProjectsParams, getOrganizationProjectsQuery } from '$features/projects/api.svelte'; import { getTableOptions } from '$features/projects/components/table/options.svelte'; @@ -92,6 +93,7 @@ + {#snippet toolbarChildren()} From b642533d6b7303594414aa25e68f645da00aac29 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:31:48 -0500 Subject: [PATCH 03/39] Improves authentication handling Enhances authentication logic by directly checking the access token to avoid reactivity issues with PersistedState. Also, resets the access token to an empty string instead of null when a 401 error is encountered. --- .../ClientApp/src/routes/(app)/+layout.svelte | 8 +++++++- src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 82545f828..17271017b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -121,6 +121,11 @@ }); $effect(() => { + // Direct read of accessToken.current establishes reactive dependency, working around PersistedState reactivity bug + const currentToken = accessToken.current; + // Track page.url to ensure effect re-runs on navigation + void page.url.pathname; + function handleKeydown(e: KeyboardEvent) { if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); @@ -128,7 +133,8 @@ } } - if (!isAuthenticated) { + // Check token directly instead of using derived isAuthenticated + if (!currentToken) { queryClient.cancelQueries(); queryClient.invalidateQueries(); diff --git a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte index b9310b853..dd351ba37 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/+layout.svelte @@ -42,7 +42,7 @@ } if (status === 401 && !ctx.options.expectedStatusCodes?.includes(401)) { - accessToken.current = null; + accessToken.current = ''; return; } From 6ae0b3ef836a3d3448ab32ef2ba914a8faed6bb5 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:32:04 -0500 Subject: [PATCH 04/39] Adds stack log level component Adds a component to display the log level associated with a stack. This enhances the stack card by providing users with quick insights into the severity of events within a stack. --- .../stacks/components/stack-card.svelte | 4 +++- .../stacks/components/stack-log-level.svelte | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index b6157c64c..0d31f1ab9 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -24,6 +24,7 @@ import Filter from '@lucide/svelte/icons/filter'; import Users from '@lucide/svelte/icons/users'; + import StackLogLevel from './stack-log-level.svelte'; import StackOptionsDropdownMenu from './stack-options-dropdown-menu.svelte'; import StackReferences from './stack-references.svelte'; import StackStatusDropdownMenu from './stack-status-dropdown-menu.svelte'; @@ -121,7 +122,8 @@ {stack.title} -
+
+ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte new file mode 100644 index 000000000..f81ba5442 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-log-level.svelte @@ -0,0 +1,17 @@ + + +{#if stack.type === 'log' && source} + +{/if} From 7cb34a42135f0f83092edf3518d1309ce35c948e Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:32:13 -0500 Subject: [PATCH 05/39] Improves stack filter tooltip appearance Enhances the stack filter tooltip to provide a better user experience by improving the visual appearance and interaction. The tooltip trigger now indicates it is clickable. It also now includes accessibility attributes. --- .../src/lib/features/stacks/components/stack-card.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index 0d31f1ab9..572c563da 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -135,8 +135,11 @@
filterChanged(new StringFilter('stack', stack.id))} + aria-label="Filter by this stack" + role="button" + tabindex={0} > From 44150de78c35afae0ada3c4a2b058be3b028d7a9 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:32:21 -0500 Subject: [PATCH 06/39] Adds skeleton for event counts Adds a skeleton loading indicator to the stack card while event counts are loading. This improves the user experience by providing visual feedback that data is being fetched. --- .../src/lib/features/stacks/components/stack-card.svelte | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte index 572c563da..214cb9882 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/stacks/components/stack-card.svelte @@ -243,6 +243,12 @@
{/each}
+ +
+ Last 7 days +
+ + {/if} From aae068dbc9a529c08904ec10c5fd3a0d10fafc32 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Wed, 29 Oct 2025 21:34:22 -0500 Subject: [PATCH 07/39] Adds Storybook stories for filter triggers Creates Storybook stories for Boolean, Date, Level, Number, Project, Reference, Session, Status, String, Tag, Type, and Version filter triggers. These stories provide visual examples of the filter trigger components, showcasing both "With Text" and "Icon Only" variations, facilitating easier development and testing. Also, refactors faceted filter trigger components to omit ButtonProps 'value' and sets the button class to 'cursor-pointer'. Removes the title const and adds a title property to the Button component. --- ...lean-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../boolean-faceted-filter-trigger.svelte | 8 ++-- ...date-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../date-faceted-filter-trigger.svelte | 10 ++--- ...evel-faceted-filter-trigger.stories.svelte | 41 ++++++++++++++++++ .../level-faceted-filter-trigger.svelte | 8 ++-- ...mber-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../number-faceted-filter-trigger.svelte | 8 ++-- ...ject-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../project-faceted-filter-trigger.svelte | 8 ++-- ...ence-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../reference-faceted-filter-trigger.svelte | 10 ++--- ...sion-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../session-faceted-filter-trigger.svelte | 10 ++--- ...atus-faceted-filter-trigger.stories.svelte | 42 +++++++++++++++++++ .../status-faceted-filter-trigger.svelte | 8 ++-- ...ring-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../string-faceted-filter-trigger.svelte | 10 ++--- .../tag-faceted-filter-trigger.stories.svelte | 40 ++++++++++++++++++ .../filters/tag-faceted-filter-trigger.svelte | 10 ++--- ...type-faceted-filter-trigger.stories.svelte | 41 ++++++++++++++++++ .../type-faceted-filter-trigger.svelte | 8 ++-- ...sion-faceted-filter-trigger.stories.svelte | 36 ++++++++++++++++ .../version-faceted-filter-trigger.svelte | 10 ++--- 24 files changed, 494 insertions(+), 66 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/date-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/level-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/number-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/project-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/reference-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/session-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/status-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/string-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/tag-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/type-faceted-filter-trigger.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/version-faceted-filter-trigger.stories.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte new file mode 100644 index 000000000..6ca11b62a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.stories.svelte @@ -0,0 +1,36 @@ + + + +
+ {}} term="fixed" value={true}> + + Fixed: true + + {}} term="hidden" value={false}> + + Hidden: false + + {}} term="critical" value={true}> + + Critical: true + +
+
+ + +
+ {}} term="fixed" value={true} /> + {}} term="hidden" value={false} /> + {}} term="critical" value={true} /> +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte index 76b4b4682..ae290b03d 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/filters/boolean-faceted-filter-trigger.svelte @@ -4,22 +4,20 @@ import { BooleanFilter } from './models.svelte'; - type Props = ButtonProps & { + type Props = Omit & { changed: (filter: BooleanFilter) => void; term: string; value?: boolean; }; let { changed, children, class: className, term, value, ...props }: Props = $props(); - - const title = `Search ${term}:${value}`; + {/if} + {/snippet} + Impersonating {name} + You are viewing this organization as a global admin. + From 7f7b5a048fbc7e5c856add22fff575c46a35d372 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 5 Dec 2025 21:57:58 -0600 Subject: [PATCH 24/39] Hides organization list during impersonation Ensures the organization list is not displayed when a user is impersonating another user. This prevents unintended actions or confusion when operating under a different user's context. --- .../ClientApp/src/routes/(app)/organization/routes.svelte.ts | 1 + src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts index 94df31958..03fa8bc5d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/organization/routes.svelte.ts @@ -11,6 +11,7 @@ export function routes(): NavigationItem[] { group: 'Settings', href: resolve('/(app)/organization/list'), icon: Settings, + show: (context) => !context.impersonating, title: 'Organizations' }, ...organizationSettingsRoutes() diff --git a/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts b/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts index 1986c2a14..951c12e4f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/routes/routes.svelte.ts @@ -17,6 +17,7 @@ export type NavigationItem = { export type NavigationItemContext = { authenticated: boolean; + impersonating?: boolean; user?: User; }; From 20c206fb2799e071570689d84737dc39acc7ed51 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 5 Dec 2025 21:58:59 -0600 Subject: [PATCH 25/39] Adds admin organization search query Introduces a new query for searching organizations in the admin section. This enables administrators to efficiently find organizations based on various criteria, improving overall management capabilities. Includes necessary interface definitions for request parameters and supports filtering by criteria, limit, mode, page, paid, and suspended status. --- .../lib/features/organizations/api.svelte.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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 34ac8a01a..7e824f994 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 @@ -21,6 +21,7 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me } export const queryKeys = { + adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const, deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const, id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)), ids: (ids: string[] | undefined) => [...queryKeys.type, ...(ids ?? [])] as const, @@ -41,6 +42,19 @@ export interface AddOrganizationUserRequest { }; } +export interface GetAdminSearchOrganizationsParams { + criteria?: string; + limit?: number; + mode?: 'stats' | undefined; + page?: number; + paid?: boolean; + suspended?: boolean; +} + +export interface GetAdminSearchOrganizationsRequest { + params?: GetAdminSearchOrganizationsParams; +} + export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -211,6 +225,29 @@ export function deleteSuspendOrganization(request: DeleteSuspendOrganizationRequ })); } +export function getAdminOrganizationsQuery(request: GetAdminSearchOrganizationsRequest) { + return createQuery, ProblemDetails>(() => ({ + enabled: () => !!accessToken.current, + queryFn: async ({ signal }: { signal: AbortSignal }) => { + const client = useFetchClient(); + const response = await client.getJSON('admin/organizations', { + params: { + criteria: request.params?.criteria, + limit: request.params?.limit ?? 10, + mode: request.params?.mode, + page: request.params?.page ?? 1, + paid: request.params?.paid, + suspended: request.params?.suspended + }, + signal + }); + + return response; + }, + queryKey: queryKeys.adminSearch(request.params ?? {}) + })); +} + export function getInvoiceQuery(request: GetInvoiceRequest) { const queryClient = useQueryClient(); From 8b5e56ef269a1d67c098cf367a330edebf1ab8fa Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Fri, 5 Dec 2025 22:10:06 -0600 Subject: [PATCH 26/39] Adds impersonation notification Adds a notification to inform users when they are impersonating an organization. This helps to improve transparency and awareness for global administrators. The notification is displayed only when a global admin is viewing an organization they do not belong to. --- .../organization-notifications.svelte | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 0500b1c32..38cd34183 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -1,13 +1,15 @@ +{#if isImpersonating && organization} + +{/if} + {#if organization} {#if isSuspended} Date: Fri, 5 Dec 2025 22:56:22 -0600 Subject: [PATCH 27/39] Extends badge variants with new color options Adds red, amber, and orange color variants to the badge component. This allows for more diverse visual cues and improved user interface expressiveness. --- .../shared/components/ui/badge/badge.svelte | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte index bfaa9c527..34d37cfbc 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/badge/badge.svelte @@ -3,17 +3,23 @@ export const badgeVariants = tv({ base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3", - variants: { - variant: { - default: - "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent", - secondary: - "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent", - destructive: - "bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white", - outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", - }, - }, + variants: { + variant: { + default: + "bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent", + destructive: + "bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white", + outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + red: + "bg-red-100 text-red-700 [a&]:hover:bg-red-200 border-transparent focus-visible:ring-red-400 dark:bg-red-900/30 dark:text-red-300 dark:[a&]:hover:bg-red-900/50", + amber: + "bg-amber-100 text-amber-700 [a&]:hover:bg-amber-200 border-transparent focus-visible:ring-amber-400 dark:bg-amber-900/30 dark:text-amber-300 dark:[a&]:hover:bg-amber-900/50", + orange: + "bg-orange-100 text-orange-700 [a&]:hover:bg-orange-200 border-transparent focus-visible:ring-orange-400 dark:bg-orange-900/30 dark:text-orange-300 dark:[a&]:hover:bg-orange-900/50", + }, + }, defaultVariants: { variant: "default", }, From e78a83d1a665feb9c9d27d322e6ecf5e738106c3 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 12:54:53 -0600 Subject: [PATCH 28/39] Updates organization notification links Updates organization notification links to use the new app router. Adds organization over limit and suspension indicators to the organization table. --- .../free-plan-notification.svelte | 3 +- .../hourly-overage-notification.svelte | 3 +- .../monthly-overage-notification.svelte | 3 +- .../organization-notifications.stories.svelte | 16 ++++++ .../premium-upgrade-notification.svelte | 3 +- .../project-configuration-notification.svelte | 3 +- .../setup-first-project-notification.svelte | 3 +- ...suspended-organization-notification.svelte | 3 +- .../over-limit-indicator.stories.svelte | 20 +++++++ .../components/over-limit-indicator.svelte | 21 ++++++++ .../suspension-indicator.stories.svelte | 27 ++++++++++ .../components/suspension-indicator.svelte | 53 +++++++++++++++++++ .../components/table/options.svelte.ts | 44 ++++++++++++++- .../table/organization-over-limit-cell.svelte | 15 ++++++ .../organization-retention-days-cell.svelte | 15 ++++++ .../table/organization-suspension-cell.svelte | 15 ++++++ .../organizations/suspension-utils.ts | 34 ++++++++++++ 17 files changed, 273 insertions(+), 8 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte index e4f57a52e..3e6bf2cc6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/free-plan-notification.svelte @@ -1,6 +1,7 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.stories.svelte new file mode 100644 index 000000000..1c0759785 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.stories.svelte @@ -0,0 +1,20 @@ + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte new file mode 100644 index 000000000..be61e15f5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/over-limit-indicator.svelte @@ -0,0 +1,21 @@ + + +{#if isOverLimit} + + + {#snippet child({ props })} + Over Limit + {/snippet} + + This organization has exceeded its monthly event limit. + +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte new file mode 100644 index 000000000..abdc9d2f4 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.stories.svelte @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte new file mode 100644 index 000000000..f10ff2497 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/suspension-indicator.svelte @@ -0,0 +1,53 @@ + + + + + {#snippet child({ props })} + + {getLabel(code)} + + {/snippet} + + + {getSuspensionDescription(code, notes)} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts index c4a77cb43..1a9f7337e 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/options.svelte.ts @@ -2,13 +2,17 @@ import type { FetchClientResponse, ProblemDetails } from '@exceptionless/fetchcl import type { CreateQueryResult } from '@tanstack/svelte-query'; import NumberFormatter from '$comp/formatters/number.svelte'; -import OrganizationsActionsCell from '$features/organizations/components/table/organization-actions-cell.svelte'; import { ViewOrganization } from '$features/organizations/models'; import { getSharedTableOptions, type TableMemoryPagingParameters } from '$features/shared/table.svelte'; import { type ColumnDef, renderComponent } from '@tanstack/svelte-table'; import type { GetOrganizationsMode, GetOrganizationsParams } from '../../api.svelte'; +import OrganizationsActionsCell from './organization-actions-cell.svelte'; +import OrganizationOverLimitCell from './organization-over-limit-cell.svelte'; +import OrganizationRetentionDaysCell from './organization-retention-days-cell.svelte'; +import OrganizationSuspensionCell from './organization-suspension-cell.svelte'; + export function getColumns(mode: GetOrganizationsMode = 'stats'): ColumnDef[] { const columns: ColumnDef[] = [ { @@ -29,6 +33,44 @@ export function getColumns(mode: GetOrg meta: { class: 'w-[200px]' } + }, + { + accessorFn: (row) => row.is_over_monthly_limit, + cell: (info) => renderComponent(OrganizationOverLimitCell, { isOverLimit: info.getValue() }), + enableSorting: false, + header: 'Over Limit', + id: 'is_over_monthly_limit', + meta: { + class: 'w-24', + defaultHidden: true + } + }, + { + accessorKey: 'retention_days', + cell: (info) => renderComponent(OrganizationRetentionDaysCell, { value: info.getValue() }), + enableSorting: false, + header: 'Retention', + meta: { + class: 'w-24', + defaultHidden: true + } + }, + { + accessorFn: (row) => row.is_suspended, + cell: (info) => { + const org = info.row.original; + return renderComponent(OrganizationSuspensionCell, { + code: org.suspension_code, + notes: org.suspension_notes + }); + }, + enableSorting: false, + header: 'Suspended', + id: 'is_suspended', + meta: { + class: 'w-28', + defaultHidden: true + } } ]; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte new file mode 100644 index 000000000..d460a61a1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-over-limit-cell.svelte @@ -0,0 +1,15 @@ + + +{#if isOverLimit} + +{:else} + - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte new file mode 100644 index 000000000..e1c7aac56 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-retention-days-cell.svelte @@ -0,0 +1,15 @@ + + +{#if value} + days +{:else} + - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte new file mode 100644 index 000000000..a004279ea --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-suspension-cell.svelte @@ -0,0 +1,15 @@ + + +{#if code} + +{:else}x - +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts new file mode 100644 index 000000000..fe8538538 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts @@ -0,0 +1,34 @@ +import { SuspensionCode } from '$features/organizations/models'; + +export function getSuspensionLabel(code: null | string | undefined): string { + switch (code) { + case 'Abuse': + return 'Abuse Detected'; + case 'Billing': + return 'Billing Issue'; + case 'Other': + return 'Other'; + case 'Overage': + return 'Over Limit'; + default: + return 'Suspended'; + } +} + +export function getSuspensionDescription(code: null | string | undefined, notes?: null | string): string { + if (notes?.trim()) { + return notes; + } + + switch (code) { + case 'Abuse': + return 'This organization has been suspended due to abuse.'; + case 'Billing': + return 'This organization has been suspended due to billing issues.'; + case 'Overage': + return 'This organization has been suspended due to exceeding usage limits.'; + case 'Other': + default: + return 'This organization has been suspended.'; + } +} From 1998d1c7f3873b6076e7961a6858888f51906af6 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 12:55:00 -0600 Subject: [PATCH 29/39] Updates svelte and lucide dependencies. Updates svelte and lucide dependencies to their latest versions. These updates likely include bug fixes, performance improvements, and new features. --- .../ClientApp/package-lock.json | 16 ++++++++-------- src/Exceptionless.Web/ClientApp/package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/package-lock.json b/src/Exceptionless.Web/ClientApp/package-lock.json index a7ddc18d9..7ae48c11b 100644 --- a/src/Exceptionless.Web/ClientApp/package-lock.json +++ b/src/Exceptionless.Web/ClientApp/package-lock.json @@ -44,7 +44,7 @@ "@chromatic-com/storybook": "^4.1.3", "@eslint/compat": "^2.0.0", "@eslint/js": "^9.39.1", - "@iconify-json/lucide": "^1.2.78", + "@iconify-json/lucide": "^1.2.79", "@playwright/test": "^1.57.0", "@storybook/addon-a11y": "^10.1.4", "@storybook/addon-docs": "^10.1.4", @@ -71,7 +71,7 @@ "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.7.2", "storybook": "^10.1.4", - "svelte": "^5.45.5", + "svelte": "^5.45.6", "svelte-check": "^4.3.4", "swagger-typescript-api": "^13.2.16", "tslib": "^2.8.1", @@ -1155,9 +1155,9 @@ } }, "node_modules/@iconify-json/lucide": { - "version": "1.2.78", - "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.78.tgz", - "integrity": "sha512-TqIzEzBCjs1IOUre/NBKhg29DkL6+Vqh93SD9V189TwIEl5Kl2dBSL7OZ0pjjF1ru8HQ1bllBo/oS0YYVUTPgA==", + "version": "1.2.79", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.79.tgz", + "integrity": "sha512-CcwoXfC2Y7UVW0PXopmXtB4Do/eUJkhAqQqOnVENEiw3FwU707TK4uyIUqdo9tlvBaFBl95wnJf3smqsTnSyKA==", "dev": true, "license": "ISC", "dependencies": { @@ -7663,9 +7663,9 @@ } }, "node_modules/svelte": { - "version": "5.45.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.5.tgz", - "integrity": "sha512-2074U+vObO5Zs8/qhxtBwdi6ZXNIhEBTzNmUFjiZexLxTdt9vq96D/0pnQELl6YcpLMD7pZ2dhXKByfGS8SAdg==", + "version": "5.45.6", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.45.6.tgz", + "integrity": "sha512-V3aVXthzPyPt1UB1wLEoXnEXpwPsvs7NHrR0xkCor8c11v71VqBj477MClqPZYyrcXrAH21sNGhOj9FJvSwXfQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", diff --git a/src/Exceptionless.Web/ClientApp/package.json b/src/Exceptionless.Web/ClientApp/package.json index 8801ba3a0..18f31b05b 100644 --- a/src/Exceptionless.Web/ClientApp/package.json +++ b/src/Exceptionless.Web/ClientApp/package.json @@ -28,7 +28,7 @@ "@chromatic-com/storybook": "^4.1.3", "@eslint/compat": "^2.0.0", "@eslint/js": "^9.39.1", - "@iconify-json/lucide": "^1.2.78", + "@iconify-json/lucide": "^1.2.79", "@playwright/test": "^1.57.0", "@storybook/addon-a11y": "^10.1.4", "@storybook/addon-docs": "^10.1.4", @@ -55,7 +55,7 @@ "prettier-plugin-svelte": "^3.4.0", "prettier-plugin-tailwindcss": "^0.7.2", "storybook": "^10.1.4", - "svelte": "^5.45.5", + "svelte": "^5.45.6", "svelte-check": "^4.3.4", "swagger-typescript-api": "^13.2.16", "tslib": "^2.8.1", From 461001e30077dc4f3af100cc4eb6790bc2aee19b Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 12:55:12 -0600 Subject: [PATCH 30/39] Refactors organization API interfaces. Moves the `GetAdminSearchOrganizationsParams` and `GetAdminSearchOrganizationsRequest` interfaces to the bottom of the file for better organization and readability. --- .../lib/features/organizations/api.svelte.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) 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 7e824f994..63c984bda 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 @@ -42,19 +42,6 @@ export interface AddOrganizationUserRequest { }; } -export interface GetAdminSearchOrganizationsParams { - criteria?: string; - limit?: number; - mode?: 'stats' | undefined; - page?: number; - paid?: boolean; - suspended?: boolean; -} - -export interface GetAdminSearchOrganizationsRequest { - params?: GetAdminSearchOrganizationsParams; -} - export interface DeleteOrganizationRequest { route: { ids: string[]; @@ -87,6 +74,19 @@ export interface DeleteSuspendOrganizationRequest { }; } +export interface GetAdminSearchOrganizationsParams { + criteria?: string; + limit?: number; + mode?: 'stats' | undefined; + page?: number; + paid?: boolean; + suspended?: boolean; +} + +export interface GetAdminSearchOrganizationsRequest { + params?: GetAdminSearchOrganizationsParams; +} + export interface GetInvoiceRequest { route: { id: string; From 5e0d7c3a29d3e4903b4e1986fdee4f0f916d5675 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 16:05:32 -0600 Subject: [PATCH 31/39] Adds impersonate organization functionality Implements the ability for admin users to impersonate organizations. This includes a new dialog for selecting an organization to impersonate, the ability to stop impersonating, and updates to the sidebar to reflect the impersonation status. It introduces pagination for the organization selection list and provides filtering options based on plan type and suspension status. Also, the pagination component was refactored to use Radix UI primitives for improved accessibility and consistency. --- .../impersonate-organization-dialog.svelte | 475 ++++++++++++++++++ .../organization-notifications.stories.svelte | 5 +- .../organizations/suspension-utils.ts | 32 +- .../data-table/data-table-pagination.svelte | 42 +- .../shared/components/ui/pagination/index.ts | 28 ++ .../ui/pagination/pagination-content.svelte | 20 + .../ui/pagination/pagination-ellipsis.svelte | 22 + .../pagination/pagination-first-button.svelte | 51 ++ .../ui/pagination/pagination-item.svelte | 14 + .../ui/pagination/pagination-link.svelte | 39 ++ .../pagination/pagination-next-button.svelte | 32 ++ .../pagination/pagination-prev-button.svelte | 32 ++ .../ui/pagination/pagination.svelte | 28 ++ .../sidebar-organization-switcher.svelte | 93 +++- .../(components)/layouts/sidebar-user.svelte | 46 +- .../(app)/(components)/layouts/sidebar.svelte | 12 +- 16 files changed, 915 insertions(+), 56 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte new file mode 100644 index 000000000..bb5c931b5 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte @@ -0,0 +1,475 @@ + + + + + + Impersonate Organization + Select an organization to view as an admin. + + +
+ + +
+ { + if (value === 'all') { + paidFilter = undefined; + } else { + paidFilter = value === 'paid'; + } + }} + > + + {#if paidFilter === undefined} + All Plans + {:else if paidFilter} + Paid + {:else} + Free + {/if} + + + All Plans + Paid + Free + + + + { + if (value === 'all') { + suspendedFilter = undefined; + } else { + suspendedFilter = value === 'suspended'; + } + }} + > + + {#if suspendedFilter === undefined} + All Status + {:else if suspendedFilter} + Suspended + {:else} + Active + {/if} + + + All Status + Active + Suspended + + + + {#if hasFilters} + + {/if} +
+ +
+
+ {#if searchResults.isFetching} +
+ {#each [1, 2, 3, 4, 5] as i (i)} +
+ +
+ + +
+
+ {/each} +
+ {:else if organizations.length > 0} +
+ {#each organizations as organization (organization.id)} + {@const isSelected = selectedOrganization?.id === organization.id} + {@const isMember = isUserMember(organization.id)} +
+ + + {#if isSelected} +
+ {#if organization.is_suspended} + + {#snippet icon()}{/snippet} + + Suspended + {#if organization.suspension_date}{/if} + for + {getSuspensionLabel(organization.suspension_code)}{#if organization.suspension_notes}: {organization.suspension_notes}{/if} + + + {/if} + + {#if organization.is_over_monthly_limit || organization.is_over_request_limit} + + {#snippet icon()}{/snippet} + + Over Limit: + {#if organization.is_over_monthly_limit}Monthly{/if} + {#if organization.is_over_request_limit}{organization.is_over_monthly_limit ? ', ' : ''}Request{/if} + + + {/if} + +
+
+
+ Plan: + {organization.plan_name} + + {getBillingStatusLabel(organization.billing_status)} + +
+ {#if organization.billing_change_date} + Changed + {/if} +
+ {#if organization.subscribe_date} +
+
+ Subscribed: + +
+
+ {/if} +
+
+ Limit: + events/mo +
+
+
+
+ Retention: + days +
+
+ {#if organization.bonus_events_per_month && organization.bonus_events_per_month > 0} +
+
+ Bonus: + events/mo +
+ {#if organization.bonus_expiration} + Expires + {/if} +
+ {/if} +
+ +
+
+ + + + projects + + + + + stacks + + + + + events + +
+ {#if organization.created_utc} + + + Created + + + {/if} + {#if organization.updated_utc && organization.created_utc !== organization.updated_utc} + + + Updated + + + {/if} + {#if getLastEventDate(organization)} + + + Last Event + + + {/if} +
+
+ {/if} +
+ {/each} +
+ {:else} +
+ +

No organizations found

+ + {#if hasFilters} + Try adjusting your search or + {:else} + Try a different search query + {/if} + +
+ {/if} +
+
+ +
+ + {#if totalCount > 0} + Showing {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, totalCount)} of {totalCount} organizations + {:else if searchResults.isFetching} + Loading... + {/if} + + {#if totalPages > 1} + + + + + + + + + + + + + + {/if} +
+
+ + + + + +
+
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte index 916651bc6..eae423706 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/organization-notifications.stories.svelte @@ -113,10 +113,7 @@
- + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts index fe8538538..dcb7db864 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/suspension-utils.ts @@ -1,20 +1,3 @@ -import { SuspensionCode } from '$features/organizations/models'; - -export function getSuspensionLabel(code: null | string | undefined): string { - switch (code) { - case 'Abuse': - return 'Abuse Detected'; - case 'Billing': - return 'Billing Issue'; - case 'Other': - return 'Other'; - case 'Overage': - return 'Over Limit'; - default: - return 'Suspended'; - } -} - export function getSuspensionDescription(code: null | string | undefined, notes?: null | string): string { if (notes?.trim()) { return notes; @@ -32,3 +15,18 @@ export function getSuspensionDescription(code: null | string | undefined, notes? return 'This organization has been suspended.'; } } + +export function getSuspensionLabel(code: null | string | undefined): string { + switch (code) { + case 'Abuse': + return 'Abuse Detected'; + case 'Billing': + return 'Billing Issue'; + case 'Other': + return 'Other'; + case 'Overage': + return 'Over Limit'; + default: + return 'Suspended'; + } +} diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte index 4f2078a10..8392bcc63 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/data-table/data-table-pagination.svelte @@ -5,31 +5,33 @@ -
- {#if table.getState().pagination.pageIndex > 1} - - {/if} - - -
+ + + + + + + + + + + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts new file mode 100644 index 000000000..c867c237c --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/index.ts @@ -0,0 +1,28 @@ +import Root from "./pagination.svelte"; +import Content from "./pagination-content.svelte"; +import Item from "./pagination-item.svelte"; +import Link from "./pagination-link.svelte"; +import FirstButton from "./pagination-first-button.svelte"; +import PrevButton from "./pagination-prev-button.svelte"; +import NextButton from "./pagination-next-button.svelte"; +import Ellipsis from "./pagination-ellipsis.svelte"; + +export { + Root, + Content, + Item, + Link, + FirstButton, + PrevButton, + NextButton, + Ellipsis, + // + Root as Pagination, + Content as PaginationContent, + FirstButton as PaginationFirstButton, + Item as PaginationItem, + Link as PaginationLink, + PrevButton as PaginationPrevButton, + NextButton as PaginationNextButton, + Ellipsis as PaginationEllipsis, +}; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte new file mode 100644 index 000000000..e1124fce1 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-content.svelte @@ -0,0 +1,20 @@ + + +
    + {@render children?.()} +
diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte new file mode 100644 index 000000000..3be94c9ca --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-ellipsis.svelte @@ -0,0 +1,22 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte new file mode 100644 index 000000000..6a42fae6e --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-first-button.svelte @@ -0,0 +1,51 @@ + + +{#snippet Fallback()} + + Go to first page +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte new file mode 100644 index 000000000..fd7ffc3a7 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-item.svelte @@ -0,0 +1,14 @@ + + +
  • + {@render children?.()} +
  • diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte new file mode 100644 index 000000000..2cdd03195 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-link.svelte @@ -0,0 +1,39 @@ + + +{#snippet Fallback()} + {page.value} +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte new file mode 100644 index 000000000..d19b7c78a --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-next-button.svelte @@ -0,0 +1,32 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte new file mode 100644 index 000000000..fbde95b66 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination-prev-button.svelte @@ -0,0 +1,32 @@ + + +{#snippet Fallback()} + +{/snippet} + + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte new file mode 100644 index 000000000..60e3471b0 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/pagination/pagination.svelte @@ -0,0 +1,28 @@ + + + diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index 01daebb6b..bf270715f 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -1,6 +1,8 @@ -{#if organizations && organizations.length > 1} +{#if organizations.length > 0 || isImpersonating} {#snippet child({ props })} - - - {getInitials(activeOrganization?.name)} + + + + {getInitials(activeOrganization?.name ?? '?')} +
    - {activeOrganization?.name} + {activeOrganization?.name ?? 'Select an organization'} + + + {#if isImpersonating} + Impersonating + {:else} + {activeOrganization?.plan_name ?? 'No organization selected'} + {/if} - {activeOrganization?.plan_name}
    @@ -65,11 +105,11 @@ sideOffset={4} > Organizations - {#if organizations} + {#if organizations.length > 0} {#each organizations as organization, index (organization.name)} onOrganizationSelected(organization)} - data-active={organization.id === selected} + data-active={organization.id === selected && !isImpersonating} class="data-[active=true]:bg-accent data-[active=true]:text-accent-foreground gap-2 p-2" > @@ -79,6 +119,13 @@ ⌘{index + 1} {/each} + {:else} + +
    +
    + No organizations available +
    {/if} {#if activeOrganization?.id} @@ -91,7 +138,6 @@ ⇧⌘go - {/if} @@ -102,6 +148,27 @@ ⇧⌘gn + + + Admin + {#if isImpersonating} + +
    +
    + Stop Impersonating +
    + {:else} + (openImpersonateDialog = true)} class="gap-2 p-2"> +
    +
    + Impersonate Organization +
    + {/if} +
    @@ -121,3 +188,7 @@
    {/if} + +{#if openImpersonateDialog} + o.id)} /> +{/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index 69de4db86..ce98dbd9e 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -1,13 +1,18 @@ {#if isLoading} @@ -174,6 +197,23 @@ + + + {#if isImpersonating} + + + Stop Impersonating + + {:else} + (openImpersonateDialog = true)}> + + Impersonate Organization + + {/if} + @@ -185,3 +225,7 @@ {/if} + +{#if openImpersonateDialog} + o.id)} /> +{/if} 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 e9c7d6544..850e52fed 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 @@ -8,17 +8,23 @@ import ChevronRight from '@lucide/svelte/icons/chevron-right'; import Settings from '@lucide/svelte/icons/settings-2'; - import type { NavigationItem } from '../../../routes.svelte'; + import type { NavigationItem, NavigationItemContext } from '../../../routes.svelte'; type Props = ComponentProps & { footer?: Snippet; header?: Snippet; + impersonating?: boolean; routes: NavigationItem[]; }; - let { footer, header, routes, ...props }: Props = $props(); + let { footer, header, impersonating = false, routes, ...props }: Props = $props(); const dashboardRoutes = $derived(routes.filter((route) => route.group === 'Dashboards')); - const settingsRoutes = $derived(routes.filter((route) => route.group === 'Settings')); + + // Settings routes need additional filtering based on navigation context + const navigationContext: NavigationItemContext = $derived({ authenticated: true, impersonating }); + const settingsRoutes = $derived( + routes.filter((route) => route.group === 'Settings').filter((route) => (route.show ? route.show(navigationContext) : true)) + ); const settingsIsActive = $derived(settingsRoutes.some((route) => route.href === page.url.pathname)); const sidebar = useSidebar(); From 0385edfad0258c5b978d3daf9266a0257cd7fa65 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 20:20:24 -0600 Subject: [PATCH 32/39] Improves organization selection and display Refactors the organization selection and display logic in the application layout. - Introduces a new `PageNumber` component for consistent pagination display. - Updates the organization switcher to handle impersonated organizations. - Redirects non-admin users to the add organization page if no organizations exist. - Ensures the first organization is selected if none is currently selected. --- .../impersonate-organization-dialog.svelte | 13 ++---- .../data-table/data-table-page-count.svelte | 19 ++------ .../shared/components/page-number.svelte | 38 +++++++++++++++ .../ClientApp/src/routes/(app)/+layout.svelte | 46 +++++++++++++++---- 4 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte index bb5c931b5..d9122f17b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte @@ -1,6 +1,7 @@ - -
    - -
    - -
    + const currentPage = $derived(table.getState().pagination.pageIndex + 1); + const totalPages = $derived(Math.max(1, table.getPageCount())); + - Page of -
    + diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte new file mode 100644 index 000000000..2cf257d85 --- /dev/null +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/page-number.svelte @@ -0,0 +1,38 @@ + + +
    + + +
    + +
    + + Page of +
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 17271017b..79954cc92 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -8,7 +8,7 @@ import { env } from '$env/dynamic/public'; import { accessToken, gotoLogin } from '$features/auth/index.svelte'; import { invalidatePersistentEventQueries } from '$features/events/api.svelte'; - import { getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; + import { getOrganizationQuery, getOrganizationsQuery, invalidateOrganizationQueries } from '$features/organizations/api.svelte'; import OrganizationNotifications from '$features/organizations/components/organization-notifications.svelte'; import { organization, showOrganizationNotifications } from '$features/organizations/context.svelte'; import { invalidateProjectQueries } from '$features/projects/api.svelte'; @@ -166,26 +166,53 @@ const meQuery = getMeQuery(); const gravatar = getGravatarFromCurrentUser(meQuery); + const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); const organizationsQuery = getOrganizationsQuery({}); + const organizations = $derived(organizationsQuery.data?.data ?? []); + + const impersonatingOrganizationId = $derived.by(() => { + const isUserOrganization = meQuery.data?.organization_ids.includes(organization.current ?? ''); + return isUserOrganization ? undefined : organization.current; + }); + + const impersonatedOrganizationQuery = getOrganizationQuery({ + route: { + get id() { + return impersonatingOrganizationId; + } + } + }); + const impersonatedOrganization = $derived(impersonatingOrganizationId ? impersonatedOrganizationQuery.data : undefined); + + // Simple organization selection - pick first available if none selected $effect(() => { if (!organizationsQuery.isSuccess) { return; } - if (!organizationsQuery.data.data || organizationsQuery.data.data.length === 0) { + const hasOrganizations = organizations.length > 0; + if (!hasOrganizations) { organization.current = undefined; - goto(resolve(`/(app)/organization/add`)); + + // Redirect non-admins to add organization page + if (!isGlobalAdmin && !organizationsQuery.isLoading) { + goto(resolve(`/(app)/organization/add`)); + } + return; } - if (!organizationsQuery.data.data.find((org) => org.id === organization.current)) { - organization.current = organizationsQuery.data.data[0]!.id; + // Select first organization if none selected + if (!organization.current) { + organization.current = organizations[0]!.id; } }); + const isImpersonating = $derived(!!impersonatedOrganization); + const filteredRoutes = $derived.by(() => { - const context: NavigationItemContext = { authenticated: isAuthenticated, user: meQuery.data }; + const context: NavigationItemContext = { authenticated: isAuthenticated, impersonating: isImpersonating, user: meQuery.data }; return routes().filter((route) => (route.show ? route.show(context) : true)); }); @@ -197,18 +224,19 @@ {#if isAuthenticated} - + {#snippet header()} {/snippet} {#snippet footer()} - + {/snippet}
    From f35e9b32e72fda262ca86962d20343c8c0752018 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 20:25:20 -0600 Subject: [PATCH 33/39] Enhances organization selection UI Improves the organization selection dialog by adding total count display and adjusting pagination layout for better responsiveness on smaller screens. --- .../impersonate-organization-dialog.svelte | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte index d9122f17b..fb584184c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte @@ -442,10 +442,22 @@
    -
    - +
    +
    + + {#if totalCount > 0} + {totalCount} organizations + {/if} +
    {#if totalPages > 1} - + From c72369ec8fbb057013b5c7281408d1f33f77a1b2 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 20:45:21 -0600 Subject: [PATCH 34/39] Uses current organization ID directly Simplifies organization notifications by using the current organization ID directly. This avoids storing the organization ID separately, streamlining the component's logic and ensuring it always reflects the currently selected organization. --- .../components/organization-notifications.svelte | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 38cd34183..1725f6536 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -27,14 +27,11 @@ let { ignoreConfigureProjects = false, ignoreFree = false, isChatEnabled, openChat, requiresPremium = false, ...restProps }: Props = $props(); - // Store the organizationId to prevent loading when switching organizations. - const organizationId = currentOrganizationId.current; - const meQuery = getMeQuery(); const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); const userOrganizationIds = $derived(meQuery.data?.organization_ids ?? []); - const isImpersonating = $derived(isGlobalAdmin && organizationId !== undefined && !userOrganizationIds.includes(organizationId)); + const isImpersonating = $derived(isGlobalAdmin && currentOrganizationId.current !== undefined && !userOrganizationIds.includes(currentOrganizationId.current)); const organizationsQuery = getOrganizationsQuery({}); const userOrganizations = $derived((organizationsQuery.data?.data ?? []).filter((org) => userOrganizationIds.includes(org.id!))); @@ -42,7 +39,7 @@ const organizationQuery = getOrganizationQuery({ route: { get id() { - return organizationId; + return currentOrganizationId.current; } } }); @@ -50,13 +47,13 @@ const projectsQuery = getOrganizationProjectsQuery({ route: { get organizationId() { - return organizationId; + return currentOrganizationId.current; } } }); const organization = $derived(organizationQuery.data); - const projects = $derived((projectsQuery.data?.data ?? []).filter((p) => p.organization_id === organizationId)); + const projects = $derived((projectsQuery.data?.data ?? []).filter((p) => p.organization_id === currentOrganizationId.current)); const projectsNeedingConfig = $derived(projects.filter((p) => p.is_configured === false)); const suspensionCode: SuspensionCode | undefined = $derived( From cccb18796f3f7ee079f7b30452b4a6cda6142420 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 21:53:12 -0600 Subject: [PATCH 35/39] Enhances organization impersonation flow Improves the organization impersonation feature by streamlining navigation and updating the organization context directly. This change simplifies the process of switching between organizations when impersonating, ensuring a smoother user experience. It also fixes an issue where query string parameters might be applied during redirection. --- .../impersonate-organization-dialog.svelte | 8 ++--- .../impersonation-notification.svelte | 10 ++----- .../organization-notifications.svelte | 4 ++- .../table/organization-actions-cell.svelte | 6 ++-- .../sidebar-organization-switcher.svelte | 30 +++++++++---------- .../(components)/layouts/sidebar-user.svelte | 12 +++----- .../ClientApp/src/routes/(app)/+layout.svelte | 2 +- .../ClientApp/src/routes/(app)/+page.svelte | 1 + .../(app)/project/[projectId]/+layout.svelte | 2 +- 9 files changed, 34 insertions(+), 41 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte index fb584184c..a7b3f2e58 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/dialogs/impersonate-organization-dialog.svelte @@ -35,12 +35,12 @@ import UserRoundSearch from '@lucide/svelte/icons/user-round-search'; interface Props { - onSelect: (organization: ViewOrganization) => void; + impersonateOrganization: (organization: ViewOrganization) => Promise; open: boolean; userOrganizationIds?: string[]; } - let { onSelect, open = $bindable(), userOrganizationIds = [] }: Props = $props(); + let { impersonateOrganization, open = $bindable(), userOrganizationIds = [] }: Props = $props(); let searchQuery = $state(''); let paidFilter = $state(undefined); @@ -89,9 +89,9 @@ selectedOrganization = organization; } - function handleImpersonate() { + async function handleImpersonate() { if (selectedOrganization) { - onSelect(selectedOrganization); + await impersonateOrganization(selectedOrganization); open = false; searchQuery = ''; selectedOrganization = null; diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte index 76e1c8999..b6b558a0b 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte @@ -2,8 +2,6 @@ import type { NotificationProps } from '$comp/notification'; import type { ViewOrganization } from '$features/organizations/models'; - import { goto } from '$app/navigation'; - import { resolve } from '$app/paths'; import { Notification, NotificationDescription, NotificationTitle } from '$comp/notification'; import { Button } from '$comp/ui/button'; import { organization } from '$features/organizations/context.svelte'; @@ -16,12 +14,8 @@ let { name, userOrganizations, ...restProps }: Props = $props(); - async function stopImpersonating() { - if (userOrganizations.length > 0) { - const defaultOrganization = userOrganizations[0]!; - organization.current = defaultOrganization.id; - await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: defaultOrganization.id })); - } + function stopImpersonating() { + organization.current = userOrganizations[0]?.id; } diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte index 1725f6536..36a2208b2 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/organization-notifications.svelte @@ -31,7 +31,9 @@ const isGlobalAdmin = $derived(!!meQuery.data?.roles?.includes('global')); const userOrganizationIds = $derived(meQuery.data?.organization_ids ?? []); - const isImpersonating = $derived(isGlobalAdmin && currentOrganizationId.current !== undefined && !userOrganizationIds.includes(currentOrganizationId.current)); + const isImpersonating = $derived( + isGlobalAdmin && currentOrganizationId.current !== undefined && !userOrganizationIds.includes(currentOrganizationId.current) + ); const organizationsQuery = getOrganizationsQuery({}); const userOrganizations = $derived((organizationsQuery.data?.data ?? []).filter((org) => userOrganizationIds.includes(org.id!))); diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte index 2aeb29960..75d11ae0c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/table/organization-actions-cell.svelte @@ -83,15 +83,15 @@ {/snippet} - goto(resolve(`/(app)/organization/[organizationId]/manage`, { organizationId: org.id }))}> + goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: org.id }))}> Edit - goto(resolve(`/(app)/organization/[organizationId]/billing`, { organizationId: org.id }))}> + goto(resolve('/(app)/organization/[organizationId]/billing', { organizationId: org.id }))}> Change Plan - goto(resolve(`/(app)/organization/[organizationId]/billing`, { organizationId: org.id }))}> + goto(resolve('/(app)/organization/[organizationId]/billing', { organizationId: org.id }))}> View Invoices diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index bf270715f..8260d6f35 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -22,16 +22,16 @@ import UserRoundSearch from '@lucide/svelte/icons/user-round-search'; type Props = HTMLAttributes & { + currentOrganizationId: string | undefined; impersonatedOrganization: undefined | ViewOrganization; isLoading: boolean; organizations: undefined | ViewOrganization[]; - selected: string | undefined; }; - let { class: className, impersonatedOrganization, isLoading, organizations = [], selected = $bindable() }: Props = $props(); + let { class: className, currentOrganizationId = $bindable(), impersonatedOrganization, isLoading, organizations = [] }: Props = $props(); const sidebar = useSidebar(); - const activeOrganization = $derived(impersonatedOrganization ?? organizations.find((organization) => organization.id === selected)); + const activeOrganization = $derived(impersonatedOrganization ?? organizations.find((organization) => organization.id === currentOrganizationId)); const isImpersonating = $derived(!!impersonatedOrganization); let openImpersonateDialog = $state(false); @@ -40,24 +40,20 @@ sidebar.toggle(); } - if (organization.id === selected) { + if (organization.id === currentOrganizationId) { return; } - selected = organization.id; + currentOrganizationId = organization.id; } async function handleImpersonate(organization: ViewOrganization): Promise { - selected = organization.id; - await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: organization.id })); + currentOrganizationId = organization.id; + await goto(resolve('/(app)')); } - async function stopImpersonating(): Promise { - if (organizations.length > 0) { - const defaultOrganization = organizations[0]!; - selected = defaultOrganization.id; - await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: defaultOrganization.id })); - } + function stopImpersonating(): void { + currentOrganizationId = organizations[0]?.id; } @@ -109,7 +105,7 @@ {#each organizations as organization, index (organization.name)} onOrganizationSelected(organization)} - data-active={organization.id === selected && !isImpersonating} + data-active={organization.id === currentOrganizationId && !isImpersonating} class="data-[active=true]:bg-accent data-[active=true]:text-accent-foreground gap-2 p-2" > @@ -190,5 +186,9 @@ {/if} {#if openImpersonateDialog} - o.id)} /> + o.id)} + /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index ce98dbd9e..72bfb020b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -46,17 +46,13 @@ } } - async function handleImpersonate(vo: ViewOrganization): Promise { + async function impersonateOrganization(vo: ViewOrganization): Promise { organization.current = vo.id; - await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: vo.id })); + await goto(resolve('/(app)')); } async function stopImpersonating(): Promise { - if (organizations.length > 0) { - const defaultOrganization = organizations[0]!; - organization.current = defaultOrganization.id; - await goto(resolve('/(app)/organization/[organizationId]/manage', { organizationId: defaultOrganization.id })); - } + organization.current = organizations[0]?.id; } @@ -227,5 +223,5 @@ {/if} {#if openImpersonateDialog} - o.id)} /> + o.id)} /> {/if} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte index 79954cc92..075a128cb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+layout.svelte @@ -231,7 +231,7 @@ isLoading={organizationsQuery.isLoading} {organizations} {impersonatedOrganization} - bind:selected={organization.current} + bind:currentOrganizationId={organization.current} /> {/snippet} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 13eef9e69..3da7076ba 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -77,6 +77,7 @@ } }); + // NOTE: This might be applying query string parameters when redirecting away. watch( () => organization.current, () => { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte index c8d5fefad..0251660d6 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/project/[projectId]/+layout.svelte @@ -76,7 +76,7 @@ {/if} -
    From 1fd176b52d5cf9a27f2fd38508c60548c714e1df Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sat, 6 Dec 2025 22:04:34 -0600 Subject: [PATCH 36/39] Updates routes to use resolve function Updates all internal links to use the resolve function for improved route handling and consistency. This change ensures that all routes are correctly resolved, regardless of the base URL or environment configuration. It replaces hardcoded paths with dynamically generated paths using the resolve function. --- .../summary/stack-error-summary.svelte | 3 +- .../summary/stack-feature-summary.svelte | 3 +- .../summary/stack-log-summary.svelte | 3 +- .../summary/stack-not-found-summary.svelte | 3 +- .../summary/stack-session-summary.svelte | 3 +- .../summary/stack-simple-summary.svelte | 3 +- .../components/summary/stack-summary.svelte | 3 +- .../free-plan-notification.svelte | 2 +- .../hourly-overage-notification.svelte | 2 +- .../monthly-overage-notification.svelte | 2 +- .../premium-upgrade-notification.svelte | 2 +- ...suspended-organization-notification.svelte | 2 +- .../sidebar-organization-switcher.svelte | 8 +++-- .../(components)/layouts/sidebar-user.svelte | 12 +++---- .../ClientApp/src/routes/(app)/+page.svelte | 6 +++- .../(app)/account/security/+page.svelte | 33 ++++++++++++++----- .../src/routes/(app)/issues/+page.svelte | 6 ++-- .../[organizationId]/billing/+page.svelte | 3 +- .../[organizationId]/manage/+page.svelte | 2 +- .../(app)/project/[projectId]/+layout.svelte | 2 +- .../project/[projectId]/manage/+page.svelte | 2 +- .../routes/(app)/redirect-to-events.svelte.ts | 2 +- .../src/routes/(app)/stream/+page.svelte | 6 +++- 23 files changed, 76 insertions(+), 37 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte index d7b638254..d89813b08 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/summary/stack-error-summary.svelte @@ -1,6 +1,7 @@ diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index 8260d6f35..2c9f96180 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -126,7 +126,11 @@ {#if activeOrganization?.id} - +
    @@ -136,7 +140,7 @@
    {/if} - +
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index 72bfb020b..7cf0f616b 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -118,12 +118,12 @@ -
    Account + Account ⇧⌘ga - Notifications + Notifications ⇧⌘gn {#if organization.current} @@ -131,7 +131,7 @@ @@ -143,7 +143,7 @@ @@ -154,7 +154,7 @@ {:else} - + Add organization ⇧⌘gn @@ -213,7 +213,7 @@ - Log out + Log out ⇧⌘Q diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 3da7076ba..6c7cb6db1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -303,7 +303,11 @@ Event Details Event Details diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte index 50734e1cc..4e7e749b4 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/account/security/+page.svelte @@ -1,4 +1,5 @@ -
    {#each errors.reverse() as error, index (index)}{#if index < errors.length - 1}
    {/if}{/each}
    diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte index 4659bd62a..87cdf795c 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/events/components/stack-trace/stack-trace.svelte @@ -13,10 +13,10 @@ let { error }: Props = $props(); - const errors = getErrors(error); + const errors = $derived(getErrors(error)); -
    {#each errors.reverse() as error, index (index)}{#if index < errors.length - 1}
    {/if}{/each}
    diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte index ae57fe6ba..80894099f 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/formatters/bytes.svelte @@ -5,7 +5,7 @@ let { value }: Props = $props(); - const parsedValue = typeof value === 'number' ? value : parseFloat(value ?? ''); + const parsedValue = $derived(typeof value === 'number' ? value : parseFloat(value ?? '')); const byteValueNumberFormatter = new Intl.NumberFormat(navigator.language, { notation: 'compact', style: 'unit', diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte index f17894f3f..7bdf92093 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/object-dump.svelte @@ -13,11 +13,11 @@ let { value }: Props = $props(); - let type = typeof value; - let isBoolean = type === 'boolean' || value instanceof Boolean; - let isObject = (type === 'object' || value instanceof Object) && value !== null; - let isNull = value === null; - let isEmptyValue = isEmpty(value); + const type = $derived(typeof value); + const isBoolean = $derived(type === 'boolean' || value instanceof Boolean); + const isObject = $derived((type === 'object' || value instanceof Object) && value !== null); + const isNull = $derived(value === null); + const isEmptyValue = $derived(isEmpty(value)); function isEmpty(value: unknown) { if (value === undefined) { diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte index b5458fabf..7431eee35 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/shared/components/ui/breadcrumb/breadcrumb-list.svelte @@ -14,7 +14,7 @@ bind:this={ref} data-slot="breadcrumb-list" class={cn( - "text-muted-foreground flex flex-wrap items-center gap-1.5 break-words text-sm sm:gap-2.5", + "text-muted-foreground flex flex-wrap items-center gap-1.5 wrap-break-word text-sm sm:gap-2.5", className )} {...restProps} diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte index a71b22cc8..c18d46581 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/navigation-command.svelte @@ -6,12 +6,14 @@ let { open = $bindable(), routes }: { open: boolean; routes: NavigationItem[] } = $props(); - const groupedRoutes: Record = Object.entries(Object.groupBy(routes, (item: NavigationItem) => item.group)).reduce( - (acc, [key, value]) => { - if (value) acc[key] = value; - return acc; - }, - {} as Record + const groupedRoutes = $derived( + Object.entries(Object.groupBy(routes, (item: NavigationItem) => item.group)).reduce( + (acc, [key, value]) => { + if (value) acc[key] = value; + return acc; + }, + {} as Record + ) ); function closeCommandWindow() { diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte index 6c7cb6db1..403c2a72d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/+page.svelte @@ -304,7 +304,7 @@ Event Details {#if resolvedMode === 'light'} @@ -16,7 +16,7 @@
    -
    +
    @@ -36,7 +36,7 @@
    -
    +
    diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte index f1cee3071..c80f9439d 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/issues/+page.svelte @@ -330,8 +330,11 @@ Event Details Event Details 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 39dba73ea..5b81d822c 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/stream/+page.svelte @@ -281,7 +281,7 @@ Event Details Date: Sat, 6 Dec 2025 22:30:34 -0600 Subject: [PATCH 38/39] Navigates to app route before updating state Ensures the application navigates to the main app route before updating the organization state during impersonation or when stopping impersonation. This resolves potential issues where the state update might occur before the route is fully loaded, leading to inconsistent behavior. --- .../notifications/impersonation-notification.svelte | 5 ++++- .../layouts/sidebar-organization-switcher.svelte | 5 +++-- .../routes/(app)/(components)/layouts/sidebar-user.svelte | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte index b6b558a0b..777f521c6 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte +++ b/src/Exceptionless.Web/ClientApp/src/lib/features/organizations/components/notifications/impersonation-notification.svelte @@ -2,6 +2,8 @@ import type { NotificationProps } from '$comp/notification'; import type { ViewOrganization } from '$features/organizations/models'; + import { goto } from '$app/navigation'; + import { resolve } from '$app/paths'; import { Notification, NotificationDescription, NotificationTitle } from '$comp/notification'; import { Button } from '$comp/ui/button'; import { organization } from '$features/organizations/context.svelte'; @@ -14,7 +16,8 @@ let { name, userOrganizations, ...restProps }: Props = $props(); - function stopImpersonating() { + async function stopImpersonating(): Promise { + await goto(resolve('/(app)')); organization.current = userOrganizations[0]?.id; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte index 2c9f96180..f34a303b1 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-organization-switcher.svelte @@ -48,11 +48,12 @@ } async function handleImpersonate(organization: ViewOrganization): Promise { - currentOrganizationId = organization.id; await goto(resolve('/(app)')); + currentOrganizationId = organization.id; } - function stopImpersonating(): void { + async function stopImpersonating(): Promise { + await goto(resolve('/(app)')); currentOrganizationId = organizations[0]?.id; } diff --git a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte index 7cf0f616b..4294ef2eb 100644 --- a/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte +++ b/src/Exceptionless.Web/ClientApp/src/routes/(app)/(components)/layouts/sidebar-user.svelte @@ -47,11 +47,12 @@ } async function impersonateOrganization(vo: ViewOrganization): Promise { - organization.current = vo.id; await goto(resolve('/(app)')); + organization.current = vo.id; } async function stopImpersonating(): Promise { + await goto(resolve('/(app)')); organization.current = organizations[0]?.id; } From 921074dfc4340dbd36c4eea18e241b9d1ea99231 Mon Sep 17 00:00:00 2001 From: Blake Niemyjski Date: Sun, 7 Dec 2025 12:03:16 -0600 Subject: [PATCH 39/39] Regenerated api.ts Comparing the uncommitted changes to the git branch version, here are the actual code differences (ignoring comments, whitespace, and quote styles): Key Changes: Added // @ts-nocheck at line 3 Moved StackStatus enum to the top of the file (before BillingStatus) Removed multi-line comment documentation for BillingStatus enum (the one explaining 0=Trialing, 1=Active, etc.) Removed multi-line comment documentation for StackStatus enum (the one explaining the enum values) Reordered enum exports - StackStatus now comes before BillingStatus Changed card_last4 property in ViewOrganization - was quoted as a string key ('card_last4') in the git version, now it's a regular identifier (card_last4) Removed multi-line comment for BillingStatus enum in the ViewOrganization class (the one explaining billing status values) Reformatted code with different indentation and line breaks (but this is purely whitespace) Summary: The substantive code changes are: Addition of @ts-nocheck directive Reordering of StackStatus and BillingStatus enums Removal of verbose comment documentation for enum values Fix of card_last4 property formatting (removed unnecessary quotes) --- .../ClientApp/src/lib/generated/api.ts | 509 +++++++++++------- 1 file changed, 315 insertions(+), 194 deletions(-) diff --git a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts index 1521f0921..e295ab5af 100644 --- a/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts +++ b/src/Exceptionless.Web/ClientApp/src/lib/generated/api.ts @@ -1,5 +1,6 @@ /* eslint-disable */ /* tslint:disable */ +// @ts-nocheck /* * --------------------------------------------------------------- * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## @@ -13,6 +14,24 @@ import { IsDate, IsDefined, IsEmail, IsInt, IsMongoId, IsNumber, IsOptional, IsUrl, MaxLength, MinLength, ValidateNested } from 'class-validator'; +export enum StackStatus { + Open = 'open', + Fixed = 'fixed', + Regressed = 'regressed', + Snoozed = 'snoozed', + Ignored = 'ignored', + Discarded = 'discarded' +} + +/** @format int32 */ +export enum BillingStatus { + Trialing = 0, + Active = 1, + PastDue = 2, + Canceled = 3, + Unpaid = 4 +} + export class BillingPlan { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; @IsDefined({ message: 'name is required.' }) name!: string; @@ -20,42 +39,28 @@ export class BillingPlan { /** @format double */ @IsNumber({}, { message: 'price must be a numeric value.' }) price!: number; /** @format int32 */ - @IsInt({ message: 'max_projects must be a whole number.' }) max_projects!: number; + @IsInt({ message: 'max_projects must be a whole number.' }) + max_projects!: number; /** @format int32 */ @IsInt({ message: 'max_users must be a whole number.' }) max_users!: number; /** @format int32 */ - @IsInt({ message: 'retention_days must be a whole number.' }) retention_days!: number; + @IsInt({ message: 'retention_days must be a whole number.' }) + retention_days!: number; /** @format int32 */ - @IsInt({ message: 'max_events_per_month must be a whole number.' }) max_events_per_month!: number; - @IsDefined({ message: 'has_premium_features is required.' }) has_premium_features!: boolean; + @IsInt({ message: 'max_events_per_month must be a whole number.' }) + max_events_per_month!: number; + @IsDefined({ message: 'has_premium_features is required.' }) + has_premium_features!: boolean; @IsDefined({ message: 'is_hidden is required.' }) is_hidden!: boolean; } -/** - * - * - * 0 = Trialing - * - * 1 = Active - * - * 2 = PastDue - * - * 3 = Canceled - * - * 4 = Unpaid - * @format int32 - */ -export enum BillingStatus { - Trialing = 0, - Active = 1, - PastDue = 2, - Canceled = 3, - Unpaid = 4 -} - export class ChangePasswordModel { - @MinLength(6, { message: 'current_password must be at least 6 characters long.' }) - @MaxLength(100, { message: 'current_password must be at most 100 characters long.' }) + @MinLength(6, { + message: 'current_password must be at least 6 characters long.' + }) + @MaxLength(100, { + message: 'current_password must be at most 100 characters long.' + }) current_password!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) @@ -70,20 +75,28 @@ export class ChangePlanResult { export class ClientConfiguration { /** @format int32 */ @IsInt({ message: 'version must be a whole number.' }) version!: number; - @ValidateNested({ message: 'settings must be a valid nested object.' }) settings!: Record; + @ValidateNested({ message: 'settings must be a valid nested object.' }) + settings!: Record; } export class CountResult { /** @format int64 */ @IsInt({ message: 'total must be a whole number.' }) total!: number; - @IsOptional() @ValidateNested({ message: 'aggregations must be a valid nested object.' }) aggregations?: Record; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; + @IsOptional() + @ValidateNested({ message: 'aggregations must be a valid nested object.' }) + aggregations?: Record; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; } export class ExternalAuthInfo { - @MinLength(1, { message: 'clientId must be at least 1 characters long.' }) clientId!: string; - @MinLength(1, { message: 'code must be at least 1 characters long.' }) code!: string; - @MinLength(1, { message: 'redirectUri must be at least 1 characters long.' }) redirectUri!: string; + @MinLength(1, { message: 'clientId must be at least 1 characters long.' }) + clientId!: string; + @MinLength(1, { message: 'code must be at least 1 characters long.' }) + code!: string; + @MinLength(1, { message: 'redirectUri must be at least 1 characters long.' }) + redirectUri!: string; @IsOptional() inviteToken?: string | null; } @@ -95,19 +108,23 @@ export class Invite { @IsDefined({ message: 'token is required.' }) token!: string; @IsDefined({ message: 'email_address is required.' }) email_address!: string; /** @format date-time */ - @IsDate({ message: 'date_added must be a valid date and time.' }) date_added!: string; + @IsDate({ message: 'date_added must be a valid date and time.' }) + date_added!: string; } export class Invoice { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsDefined({ message: 'organization_name is required.' }) organization_name!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsDefined({ message: 'organization_name is required.' }) + organization_name!: string; /** @format date-time */ @IsDate({ message: 'date must be a valid date and time.' }) date!: string; @IsDefined({ message: 'paid is required.' }) paid!: boolean; /** @format double */ @IsNumber({}, { message: 'total must be a numeric value.' }) total!: number; - @ValidateNested({ message: 'items must be a valid nested object.' }) items!: InvoiceLineItem[]; + @ValidateNested({ message: 'items must be a valid nested object.' }) + items!: InvoiceLineItem[]; } export class InvoiceGridModel { @@ -126,13 +143,18 @@ export class InvoiceLineItem { export class Login { /** The email address or domain username */ - @MinLength(1, { message: 'email must be at least 1 characters long.' }) email!: string; + @MinLength(1, { message: 'email must be at least 1 characters long.' }) + email!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) password!: string; @IsOptional() - @MinLength(40, { message: 'invite_token must be at least 40 characters long.' }) - @MaxLength(40, { message: 'invite_token must be at most 40 characters long.' }) + @MinLength(40, { + message: 'invite_token must be at least 40 characters long.' + }) + @MaxLength(40, { + message: 'invite_token must be at most 40 characters long.' + }) invite_token?: string | null; } @@ -141,24 +163,34 @@ export class NewOrganization { } export class NewProject { - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; @IsDefined({ message: 'name is required.' }) name!: string; - @IsDefined({ message: 'delete_bot_data_enabled is required.' }) delete_bot_data_enabled!: boolean; + @IsDefined({ message: 'delete_bot_data_enabled is required.' }) + delete_bot_data_enabled!: boolean; } export class NewToken { - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; - @IsOptional() @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) default_project_id?: string | null; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; + @IsOptional() + @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) + default_project_id?: string | null; @IsDefined({ message: 'scopes is required.' }) scopes!: string[]; /** @format date-time */ - @IsOptional() @IsDate({ message: 'expires_utc must be a valid date and time.' }) expires_utc?: string | null; + @IsOptional() + @IsDate({ message: 'expires_utc must be a valid date and time.' }) + expires_utc?: string | null; @IsOptional() notes?: string | null; } export class NewWebHook { - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; @IsUrl({}, { message: 'url must be a valid URL.' }) url!: string; @IsDefined({ message: 'event_types is required.' }) event_types!: string[]; /** The schema version that should be used. */ @@ -166,30 +198,44 @@ export class NewWebHook { } export class NotificationSettings { - @IsDefined({ message: 'send_daily_summary is required.' }) send_daily_summary!: boolean; - @IsDefined({ message: 'report_new_errors is required.' }) report_new_errors!: boolean; - @IsDefined({ message: 'report_critical_errors is required.' }) report_critical_errors!: boolean; - @IsDefined({ message: 'report_event_regressions is required.' }) report_event_regressions!: boolean; - @IsDefined({ message: 'report_new_events is required.' }) report_new_events!: boolean; - @IsDefined({ message: 'report_critical_events is required.' }) report_critical_events!: boolean; + @IsDefined({ message: 'send_daily_summary is required.' }) + send_daily_summary!: boolean; + @IsDefined({ message: 'report_new_errors is required.' }) + report_new_errors!: boolean; + @IsDefined({ message: 'report_critical_errors is required.' }) + report_critical_errors!: boolean; + @IsDefined({ message: 'report_event_regressions is required.' }) + report_event_regressions!: boolean; + @IsDefined({ message: 'report_new_events is required.' }) + report_new_events!: boolean; + @IsDefined({ message: 'report_critical_events is required.' }) + report_critical_events!: boolean; } export class OAuthAccount { @IsDefined({ message: 'provider is required.' }) provider!: string; - @IsMongoId({ message: 'provider_user_id must be a valid ObjectId.' }) provider_user_id!: string; + @IsMongoId({ message: 'provider_user_id must be a valid ObjectId.' }) + provider_user_id!: string; @IsDefined({ message: 'username is required.' }) username!: string; - @ValidateNested({ message: 'extra_data must be a valid nested object.' }) extra_data!: Record; + @ValidateNested({ message: 'extra_data must be a valid nested object.' }) + extra_data!: Record; } export class PersistentEvent { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; - @IsMongoId({ message: 'stack_id must be a valid ObjectId.' }) stack_id!: string; - @IsDefined({ message: 'is_first_occurrence is required.' }) is_first_occurrence!: boolean; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; + @IsMongoId({ message: 'stack_id must be a valid ObjectId.' }) + stack_id!: string; + @IsDefined({ message: 'is_first_occurrence is required.' }) + is_first_occurrence!: boolean; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; - @ValidateNested({ message: 'idx must be a valid nested object.' }) idx!: Record; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; + @ValidateNested({ message: 'idx must be a valid nested object.' }) + idx!: Record; @IsOptional() type?: string | null; @IsOptional() source?: string | null; /** @format date-time */ @@ -198,16 +244,26 @@ export class PersistentEvent { @IsOptional() message?: string | null; @IsOptional() geo?: string | null; /** @format double */ - @IsOptional() @IsNumber({}, { message: 'value must be a numeric value.' }) value?: number | null; + @IsOptional() + @IsNumber({}, { message: 'value must be a numeric value.' }) + value?: number | null; /** @format int32 */ @IsOptional() @IsInt({ message: 'count must be a whole number.' }) count?: number | null; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; - @IsOptional() @IsMongoId({ message: 'reference_id must be a valid ObjectId.' }) reference_id?: string | null; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; + @IsOptional() + @IsMongoId({ message: 'reference_id must be a valid ObjectId.' }) + reference_id?: string | null; } export class ResetPasswordModel { - @MinLength(40, { message: 'password_reset_token must be at least 40 characters long.' }) - @MaxLength(40, { message: 'password_reset_token must be at most 40 characters long.' }) + @MinLength(40, { + message: 'password_reset_token must be at least 40 characters long.' + }) + @MaxLength(40, { + message: 'password_reset_token must be at most 40 characters long.' + }) password_reset_token!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) @@ -215,82 +271,71 @@ export class ResetPasswordModel { } export class Signup { - @MinLength(1, { message: 'name must be at least 1 characters long.' }) name!: string; + @MinLength(1, { message: 'name must be at least 1 characters long.' }) + name!: string; /** The email address or domain username */ - @MinLength(1, { message: 'email must be at least 1 characters long.' }) email!: string; + @MinLength(1, { message: 'email must be at least 1 characters long.' }) + email!: string; @MinLength(6, { message: 'password must be at least 6 characters long.' }) @MaxLength(100, { message: 'password must be at most 100 characters long.' }) password!: string; @IsOptional() - @MinLength(40, { message: 'invite_token must be at least 40 characters long.' }) - @MaxLength(40, { message: 'invite_token must be at most 40 characters long.' }) + @MinLength(40, { + message: 'invite_token must be at least 40 characters long.' + }) + @MaxLength(40, { + message: 'invite_token must be at most 40 characters long.' + }) invite_token?: string | null; } export class Stack { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; @IsDefined({ message: 'type is required.' }) type!: string; - /** - * - * open - * fixed - * regressed - * snoozed - * ignored - * discarded - */ @IsDefined({ message: 'status is required.' }) status!: StackStatus; /** @format date-time */ - @IsOptional() @IsDate({ message: 'snooze_until_utc must be a valid date and time.' }) snooze_until_utc?: string | null; - @IsDefined({ message: 'signature_hash is required.' }) signature_hash!: string; - @ValidateNested({ message: 'signature_info must be a valid nested object.' }) signature_info!: Record; + @IsOptional() + @IsDate({ message: 'snooze_until_utc must be a valid date and time.' }) + snooze_until_utc?: string | null; + @IsDefined({ message: 'signature_hash is required.' }) + signature_hash!: string; + @ValidateNested({ message: 'signature_info must be a valid nested object.' }) + signature_info!: Record; @IsOptional() fixed_in_version?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'date_fixed must be a valid date and time.' }) date_fixed?: string | null; + @IsOptional() + @IsDate({ message: 'date_fixed must be a valid date and time.' }) + date_fixed?: string | null; @IsDefined({ message: 'title is required.' }) title!: string; /** @format int32 */ - @IsInt({ message: 'total_occurrences must be a whole number.' }) total_occurrences!: number; + @IsInt({ message: 'total_occurrences must be a whole number.' }) + total_occurrences!: number; /** @format date-time */ - @IsDate({ message: 'first_occurrence must be a valid date and time.' }) first_occurrence!: string; + @IsDate({ message: 'first_occurrence must be a valid date and time.' }) + first_occurrence!: string; /** @format date-time */ - @IsDate({ message: 'last_occurrence must be a valid date and time.' }) last_occurrence!: string; + @IsDate({ message: 'last_occurrence must be a valid date and time.' }) + last_occurrence!: string; @IsOptional() description?: string | null; - @IsDefined({ message: 'occurrences_are_critical is required.' }) occurrences_are_critical!: boolean; + @IsDefined({ message: 'occurrences_are_critical is required.' }) + occurrences_are_critical!: boolean; @IsDefined({ message: 'references is required.' }) references!: string[]; @IsDefined({ message: 'tags is required.' }) tags!: string[]; - @IsDefined({ message: 'duplicate_signature is required.' }) duplicate_signature!: string; + @IsDefined({ message: 'duplicate_signature is required.' }) + duplicate_signature!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; @IsDefined({ message: 'is_deleted is required.' }) is_deleted!: boolean; - @IsDefined({ message: 'allow_notifications is required.' }) allow_notifications!: boolean; -} - -/** - * - * - * open - * - * fixed - * - * regressed - * - * snoozed - * - * ignored - * - * discarded - */ -export enum StackStatus { - Open = 'open', - Fixed = 'fixed', - Regressed = 'regressed', - Snoozed = 'snoozed', - Ignored = 'ignored', - Discarded = 'discarded' + @IsDefined({ message: 'allow_notifications is required.' }) + allow_notifications!: boolean; } export class StringStringValuesKeyValuePair { @@ -303,7 +348,8 @@ export class StringValueFromBody { } export class TokenResult { - @MinLength(1, { message: 'token must be at least 1 characters long.' }) token!: string; + @MinLength(1, { message: 'token must be at least 1 characters long.' }) + token!: string; } export class UpdateEmailAddressResult { @@ -340,47 +386,69 @@ export class UsageInfo { export class User { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsDefined({ message: 'organization_ids is required.' }) organization_ids!: string[]; + @IsDefined({ message: 'organization_ids is required.' }) + organization_ids!: string[]; @IsOptional() password?: string | null; @IsOptional() salt?: string | null; @IsOptional() password_reset_token?: string | null; /** @format date-time */ - @IsDate({ message: 'password_reset_token_expiration must be a valid date and time.' }) password_reset_token_expiration!: string; - @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) o_auth_accounts!: OAuthAccount[]; - @MinLength(1, { message: 'full_name must be at least 1 characters long.' }) full_name!: string; + @IsDate({ + message: 'password_reset_token_expiration must be a valid date and time.' + }) + password_reset_token_expiration!: string; + @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) + o_auth_accounts!: OAuthAccount[]; + @MinLength(1, { message: 'full_name must be at least 1 characters long.' }) + full_name!: string; /** @format email */ @IsEmail({ require_tld: false }, { message: 'email_address must be a valid email address.' }) - @MinLength(1, { message: 'email_address must be at least 1 characters long.' }) + @MinLength(1, { + message: 'email_address must be at least 1 characters long.' + }) email_address!: string; - @IsDefined({ message: 'email_notifications_enabled is required.' }) email_notifications_enabled!: boolean; - @IsDefined({ message: 'is_email_address_verified is required.' }) is_email_address_verified!: boolean; + @IsDefined({ message: 'email_notifications_enabled is required.' }) + email_notifications_enabled!: boolean; + @IsDefined({ message: 'is_email_address_verified is required.' }) + is_email_address_verified!: boolean; @IsOptional() verify_email_address_token?: string | null; /** @format date-time */ - @IsDate({ message: 'verify_email_address_token_expiration must be a valid date and time.' }) verify_email_address_token_expiration!: string; + @IsDate({ + message: 'verify_email_address_token_expiration must be a valid date and time.' + }) + verify_email_address_token_expiration!: string; @IsDefined({ message: 'is_active is required.' }) is_active!: boolean; @IsDefined({ message: 'roles is required.' }) roles!: string[]; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; } export class UserDescription { @IsOptional() email_address?: string | null; @IsOptional() description?: string | null; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; } export class ViewCurrentUser { @IsOptional() hash?: string | null; - @IsDefined({ message: 'has_local_account is required.' }) has_local_account!: boolean; - @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) o_auth_accounts!: OAuthAccount[]; + @IsDefined({ message: 'has_local_account is required.' }) + has_local_account!: boolean; + @ValidateNested({ message: 'o_auth_accounts must be a valid nested object.' }) + o_auth_accounts!: OAuthAccount[]; @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsDefined({ message: 'organization_ids is required.' }) organization_ids!: string[]; + @IsDefined({ message: 'organization_ids is required.' }) + organization_ids!: string[]; @IsDefined({ message: 'full_name is required.' }) full_name!: string; @IsDefined({ message: 'email_address is required.' }) email_address!: string; - @IsDefined({ message: 'email_notifications_enabled is required.' }) email_notifications_enabled!: boolean; - @IsDefined({ message: 'is_email_address_verified is required.' }) is_email_address_verified!: boolean; + @IsDefined({ message: 'email_notifications_enabled is required.' }) + email_notifications_enabled!: boolean; + @IsDefined({ message: 'is_email_address_verified is required.' }) + is_email_address_verified!: boolean; @IsDefined({ message: 'is_active is required.' }) is_active!: boolean; @IsDefined({ message: 'is_invite is required.' }) is_invite!: boolean; @IsDefined({ message: 'roles is required.' }) roles!: string[]; @@ -389,109 +457,159 @@ export class ViewCurrentUser { export class ViewOrganization { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; @IsDefined({ message: 'name is required.' }) name!: string; @IsMongoId({ message: 'plan_id must be a valid ObjectId.' }) plan_id!: string; @IsDefined({ message: 'plan_name is required.' }) plan_name!: string; - @IsDefined({ message: 'plan_description is required.' }) plan_description!: string; - @IsOptional() 'card_last4'?: string | null; + @IsDefined({ message: 'plan_description is required.' }) + plan_description!: string; + @IsOptional() card_last4?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'subscribe_date must be a valid date and time.' }) subscribe_date?: string | null; + @IsOptional() + @IsDate({ message: 'subscribe_date must be a valid date and time.' }) + subscribe_date?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'billing_change_date must be a valid date and time.' }) billing_change_date?: string | null; - @IsOptional() @IsMongoId({ message: 'billing_changed_by_user_id must be a valid ObjectId.' }) billing_changed_by_user_id?: string | null; - /** - * - * 0 = Trialing - * 1 = Active - * 2 = PastDue - * 3 = Canceled - * 4 = Unpaid - */ - @IsDefined({ message: 'billing_status is required.' }) billing_status!: BillingStatus; + @IsOptional() + @IsDate({ message: 'billing_change_date must be a valid date and time.' }) + billing_change_date?: string | null; + @IsOptional() + @IsMongoId({ + message: 'billing_changed_by_user_id must be a valid ObjectId.' + }) + billing_changed_by_user_id?: string | null; + @IsDefined({ message: 'billing_status is required.' }) + billing_status!: BillingStatus; /** @format double */ - @IsNumber({}, { message: 'billing_price must be a numeric value.' }) billing_price!: number; + @IsNumber({}, { message: 'billing_price must be a numeric value.' }) + billing_price!: number; /** @format int32 */ - @IsInt({ message: 'max_events_per_month must be a whole number.' }) max_events_per_month!: number; + @IsInt({ message: 'max_events_per_month must be a whole number.' }) + max_events_per_month!: number; /** @format int32 */ - @IsInt({ message: 'bonus_events_per_month must be a whole number.' }) bonus_events_per_month!: number; + @IsInt({ message: 'bonus_events_per_month must be a whole number.' }) + bonus_events_per_month!: number; /** @format date-time */ - @IsOptional() @IsDate({ message: 'bonus_expiration must be a valid date and time.' }) bonus_expiration?: string | null; + @IsOptional() + @IsDate({ message: 'bonus_expiration must be a valid date and time.' }) + bonus_expiration?: string | null; /** @format int32 */ - @IsInt({ message: 'retention_days must be a whole number.' }) retention_days!: number; + @IsInt({ message: 'retention_days must be a whole number.' }) + retention_days!: number; @IsDefined({ message: 'is_suspended is required.' }) is_suspended!: boolean; @IsOptional() suspension_code?: string | null; @IsOptional() suspension_notes?: string | null; /** @format date-time */ - @IsOptional() @IsDate({ message: 'suspension_date must be a valid date and time.' }) suspension_date?: string | null; - @IsDefined({ message: 'has_premium_features is required.' }) has_premium_features!: boolean; + @IsOptional() + @IsDate({ message: 'suspension_date must be a valid date and time.' }) + suspension_date?: string | null; + @IsDefined({ message: 'has_premium_features is required.' }) + has_premium_features!: boolean; /** @format int32 */ @IsInt({ message: 'max_users must be a whole number.' }) max_users!: number; /** @format int32 */ - @IsInt({ message: 'max_projects must be a whole number.' }) max_projects!: number; + @IsInt({ message: 'max_projects must be a whole number.' }) + max_projects!: number; /** @format int64 */ - @IsInt({ message: 'project_count must be a whole number.' }) project_count!: number; + @IsInt({ message: 'project_count must be a whole number.' }) + project_count!: number; /** @format int64 */ - @IsInt({ message: 'stack_count must be a whole number.' }) stack_count!: number; + @IsInt({ message: 'stack_count must be a whole number.' }) + stack_count!: number; /** @format int64 */ - @IsInt({ message: 'event_count must be a whole number.' }) event_count!: number; - @ValidateNested({ message: 'invites must be a valid nested object.' }) invites!: Invite[]; - @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) usage_hours!: UsageHourInfo[]; - @ValidateNested({ message: 'usage must be a valid nested object.' }) usage!: UsageInfo[]; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; + @IsInt({ message: 'event_count must be a whole number.' }) + event_count!: number; + @ValidateNested({ message: 'invites must be a valid nested object.' }) + invites!: Invite[]; + @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) + usage_hours!: UsageHourInfo[]; + @ValidateNested({ message: 'usage must be a valid nested object.' }) + usage!: UsageInfo[]; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; @IsDefined({ message: 'is_throttled is required.' }) is_throttled!: boolean; - @IsDefined({ message: 'is_over_monthly_limit is required.' }) is_over_monthly_limit!: boolean; - @IsDefined({ message: 'is_over_request_limit is required.' }) is_over_request_limit!: boolean; + @IsDefined({ message: 'is_over_monthly_limit is required.' }) + is_over_monthly_limit!: boolean; + @IsDefined({ message: 'is_over_request_limit is required.' }) + is_over_request_limit!: boolean; } export class ViewProject { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsDefined({ message: 'organization_name is required.' }) organization_name!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsDefined({ message: 'organization_name is required.' }) + organization_name!: string; @IsDefined({ message: 'name is required.' }) name!: string; - @IsDefined({ message: 'delete_bot_data_enabled is required.' }) delete_bot_data_enabled!: boolean; - @IsOptional() @ValidateNested({ message: 'data must be a valid nested object.' }) data?: Record; - @IsDefined({ message: 'promoted_tabs is required.' }) promoted_tabs!: string[]; + @IsDefined({ message: 'delete_bot_data_enabled is required.' }) + delete_bot_data_enabled!: boolean; + @IsOptional() + @ValidateNested({ message: 'data must be a valid nested object.' }) + data?: Record; + @IsDefined({ message: 'promoted_tabs is required.' }) + promoted_tabs!: string[]; @IsOptional() is_configured?: boolean | null; /** @format int64 */ - @IsInt({ message: 'stack_count must be a whole number.' }) stack_count!: number; + @IsInt({ message: 'stack_count must be a whole number.' }) + stack_count!: number; /** @format int64 */ - @IsInt({ message: 'event_count must be a whole number.' }) event_count!: number; - @IsDefined({ message: 'has_premium_features is required.' }) has_premium_features!: boolean; - @IsDefined({ message: 'has_slack_integration is required.' }) has_slack_integration!: boolean; - @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) usage_hours!: UsageHourInfo[]; - @ValidateNested({ message: 'usage must be a valid nested object.' }) usage!: UsageInfo[]; + @IsInt({ message: 'event_count must be a whole number.' }) + event_count!: number; + @IsDefined({ message: 'has_premium_features is required.' }) + has_premium_features!: boolean; + @IsDefined({ message: 'has_slack_integration is required.' }) + has_slack_integration!: boolean; + @ValidateNested({ message: 'usage_hours must be a valid nested object.' }) + usage_hours!: UsageHourInfo[]; + @ValidateNested({ message: 'usage must be a valid nested object.' }) + usage!: UsageInfo[]; } export class ViewToken { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; - @IsOptional() @IsMongoId({ message: 'user_id must be a valid ObjectId.' }) user_id?: string | null; - @IsOptional() @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) default_project_id?: string | null; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; + @IsOptional() + @IsMongoId({ message: 'user_id must be a valid ObjectId.' }) + user_id?: string | null; + @IsOptional() + @IsMongoId({ message: 'default_project_id must be a valid ObjectId.' }) + default_project_id?: string | null; @IsDefined({ message: 'scopes is required.' }) scopes!: string[]; /** @format date-time */ - @IsOptional() @IsDate({ message: 'expires_utc must be a valid date and time.' }) expires_utc?: string | null; + @IsOptional() + @IsDate({ message: 'expires_utc must be a valid date and time.' }) + expires_utc?: string | null; @IsOptional() notes?: string | null; @IsDefined({ message: 'is_disabled is required.' }) is_disabled!: boolean; @IsDefined({ message: 'is_suspended is required.' }) is_suspended!: boolean; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; /** @format date-time */ - @IsDate({ message: 'updated_utc must be a valid date and time.' }) updated_utc!: string; + @IsDate({ message: 'updated_utc must be a valid date and time.' }) + updated_utc!: string; } export class ViewUser { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsDefined({ message: 'organization_ids is required.' }) organization_ids!: string[]; + @IsDefined({ message: 'organization_ids is required.' }) + organization_ids!: string[]; @IsDefined({ message: 'full_name is required.' }) full_name!: string; @IsDefined({ message: 'email_address is required.' }) email_address!: string; - @IsDefined({ message: 'email_notifications_enabled is required.' }) email_notifications_enabled!: boolean; - @IsDefined({ message: 'is_email_address_verified is required.' }) is_email_address_verified!: boolean; + @IsDefined({ message: 'email_notifications_enabled is required.' }) + email_notifications_enabled!: boolean; + @IsDefined({ message: 'is_email_address_verified is required.' }) + is_email_address_verified!: boolean; @IsDefined({ message: 'is_active is required.' }) is_active!: boolean; @IsDefined({ message: 'is_invite is required.' }) is_invite!: boolean; @IsDefined({ message: 'roles is required.' }) roles!: string[]; @@ -499,14 +617,17 @@ export class ViewUser { export class WebHook { @IsMongoId({ message: 'id must be a valid ObjectId.' }) id!: string; - @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) organization_id!: string; - @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) project_id!: string; + @IsMongoId({ message: 'organization_id must be a valid ObjectId.' }) + organization_id!: string; + @IsMongoId({ message: 'project_id must be a valid ObjectId.' }) + project_id!: string; @IsUrl({}, { message: 'url must be a valid URL.' }) url!: string; @IsDefined({ message: 'event_types is required.' }) event_types!: string[]; @IsDefined({ message: 'is_enabled is required.' }) is_enabled!: boolean; @IsDefined({ message: 'version is required.' }) version!: string; /** @format date-time */ - @IsDate({ message: 'created_utc must be a valid date and time.' }) created_utc!: string; + @IsDate({ message: 'created_utc must be a valid date and time.' }) + created_utc!: string; } export class WorkInProgressResult {