From f9850f02b3af2f9f8f7381922821e97dae967a17 Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Fri, 24 Jul 2026 15:47:01 -0700 Subject: [PATCH] add session delete feature to relational only. add test, share conditions, and permissions. Closes #2940 --- .../sessions/SessionDeleteButton.tsx | 72 ++++++++++ .../sessions/SessionProfile.test.tsx | 94 +++++++++++++ .../[websiteId]/sessions/SessionProfile.tsx | 26 +++- src/app/api/config/route.ts | 2 + .../sessions/[sessionId]/route.test.ts | 125 +++++++++++++++++- .../[websiteId]/sessions/[sessionId]/route.ts | 37 +++++- src/components/hooks/useConfig.ts | 1 + src/lib/db.test.ts | 23 ++++ src/lib/db.ts | 4 + src/queries/prisma/index.ts | 1 + src/queries/prisma/session.ts | 121 +++++++++++++++++ 11 files changed, 499 insertions(+), 7 deletions(-) create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionDeleteButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProfile.test.tsx create mode 100644 src/queries/prisma/session.ts diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionDeleteButton.tsx new file mode 100644 index 000000000..7f1199157 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionDeleteButton.tsx @@ -0,0 +1,72 @@ +'use client'; +import { useQueryClient } from '@tanstack/react-query'; +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; + +const invalidationKeys = [ + ['sessions:stats'], + ['websites:stats'], + ['websites:events'], + ['websites:events:stats'], + ['websites:revenue:chart'], + ['websites:revenue:metrics'], + ['realtime'], +]; + +export function SessionDeleteButton({ + websiteId, + sessionId, + onSave, +}: { + websiteId: string; + sessionId: string; + onSave?: () => void; +}) { + const queryClient = useQueryClient(); + const { t, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery( + `/websites/${websiteId}/sessions/${sessionId}`, + ); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: async () => { + touch('sessions'); + touch('replays'); + touch('revenue-sessions'); + + await Promise.all( + invalidationKeys.map(queryKey => queryClient.invalidateQueries({ queryKey })), + ); + + onSave?.(); + close(); + }, + }); + }; + + return ( + } + title={t(labels.confirm)} + variant="quiet" + width="400px" + > + {({ close }) => ( + + )} + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.test.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.test.tsx new file mode 100644 index 000000000..d07830146 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.test.tsx @@ -0,0 +1,94 @@ +import { expect, test, vi } from 'vitest'; +import { render, screen } from '@/test/render'; +import { SessionProfile } from './SessionProfile'; + +const mockUseConfig = vi.fn(); +const mockUseWebsiteSessionQuery = vi.fn(); + +vi.mock('@/components/hooks', async () => { + const actual = await vi.importActual('@/components/hooks'); + + return { + ...actual, + useConfig: () => mockUseConfig(), + useMessages: () => ({ + t: (value: string) => value, + labels: { + activity: 'Activity', + properties: 'Properties', + replays: 'Replays', + }, + }), + useMobile: () => ({ isMobile: false }), + useWebsiteSessionQuery: () => mockUseWebsiteSessionQuery(), + }; +}); + +vi.mock('@/components/common/Avatar', () => ({ + Avatar: () =>
Avatar
, +})); + +vi.mock('@/components/common/LoadingPanel', () => ({ + LoadingPanel: ({ children, data }: { children: React.ReactNode; data: unknown }) => + data ?
{children}
: null, +})); + +vi.mock('./SessionActivity', () => ({ + SessionActivity: () =>
SessionActivity
, +})); + +vi.mock('./SessionDeleteButton', () => ({ + SessionDeleteButton: () =>
, +})); + +vi.mock('./SessionData', () => ({ + SessionData: () =>
SessionData
, +})); + +vi.mock('./SessionInfo', () => ({ + SessionInfo: () =>
SessionInfo
, +})); + +vi.mock('./SessionReplaysDataTable', () => ({ + SessionReplaysDataTable: () =>
SessionReplaysDataTable
, +})); + +vi.mock('./SessionStats', () => ({ + SessionStats: () =>
SessionStats
, +})); + +function createSessionQueryResult(canDelete: boolean) { + return { + data: { + id: 'session-1', + canDelete, + firstAt: new Date('2026-07-24T00:00:00.000Z'), + lastAt: new Date('2026-07-24T00:05:00.000Z'), + distinctId: 'distinct-1', + }, + isLoading: false, + error: undefined, + }; +} + +test('hides the delete button on share routes', () => { + mockUseConfig.mockReturnValue({ sessionDeletionEnabled: true }); + mockUseWebsiteSessionQuery.mockReturnValue(createSessionQueryResult(true)); + + render( null} />, { + route: '/share/slug/sessions?session=session-1', + }); + + expect(screen.queryByTestId('session-delete-button')).not.toBeInTheDocument(); +}); + +test('shows the delete button on regular website routes when deletion is allowed', () => { + mockUseConfig.mockReturnValue({ sessionDeletionEnabled: true }); + mockUseWebsiteSessionQuery.mockReturnValue(createSessionQueryResult(true)); + + render( null} />, { + route: '/websites/website-1/sessions?session=session-1', + }); + + expect(screen.getByTestId('session-delete-button')).toBeInTheDocument(); +}); diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx index 1c0405472..a6ccbad72 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx @@ -10,11 +10,18 @@ import { Tabs, TextField, } from '@umami/react-zen'; -import { X } from 'lucide-react'; +import { + useConfig, + useMessages, + useMobile, + useNavigation, + useWebsiteSessionQuery, +} from '@/components/hooks'; +import { X } from '@/components/icons'; import { Avatar } from '@/components/common/Avatar'; import { LoadingPanel } from '@/components/common/LoadingPanel'; -import { useMessages, useMobile, useWebsiteSessionQuery } from '@/components/hooks'; import { SessionActivity } from './SessionActivity'; +import { SessionDeleteButton } from './SessionDeleteButton'; import { SessionData } from './SessionData'; import { SessionInfo } from './SessionInfo'; import { SessionReplaysDataTable } from './SessionReplaysDataTable'; @@ -32,8 +39,14 @@ export function SessionProfile({ onClose?: () => void; }) { const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId); + const config = useConfig(); const { t, labels } = useMessages(); const { isMobile } = useMobile(); + const { pathname } = useNavigation(); + const isSharePage = pathname.includes('/share/'); + const showDeleteButton = Boolean( + !isSharePage && config?.sessionDeletionEnabled && data?.canDelete, + ); return ( {onClose && ( - + + {showDeleteButton && ( + + )}