This commit is contained in:
Mike Cao committed 2026-07-24 18:49:23 -07:00
commit 08030db836
11 files changed
+499 -7

No files matched your search

@@ -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 (
<DialogButton
aria-label={t(labels.delete)}
data-test="button-delete-session"
icon={<Trash />}
title={t(labels.confirm)}
variant="quiet"
width="400px"
>
{({ close }) => (
<ConfirmationForm
message={t(messages.confirmDelete, { target: t(labels.session) })}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={t(labels.delete)}
buttonVariant="danger"
/>
)}
</DialogButton>
);
}
@@ -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<typeof import('@/components/hooks')>('@/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: () => <div>Avatar</div>,
}));
vi.mock('@/components/common/LoadingPanel', () => ({
LoadingPanel: ({ children, data }: { children: React.ReactNode; data: unknown }) =>
data ? <div>{children}</div> : null,
}));
vi.mock('./SessionActivity', () => ({
SessionActivity: () => <div>SessionActivity</div>,
}));
vi.mock('./SessionDeleteButton', () => ({
SessionDeleteButton: () => <div data-test="session-delete-button" />,
}));
vi.mock('./SessionData', () => ({
SessionData: () => <div>SessionData</div>,
}));
vi.mock('./SessionInfo', () => ({
SessionInfo: () => <div>SessionInfo</div>,
}));
vi.mock('./SessionReplaysDataTable', () => ({
SessionReplaysDataTable: () => <div>SessionReplaysDataTable</div>,
}));
vi.mock('./SessionStats', () => ({
SessionStats: () => <div>SessionStats</div>,
}));
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(<SessionProfile websiteId="website-1" sessionId="session-1" onClose={() => 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(<SessionProfile websiteId="website-1" sessionId="session-1" onClose={() => null} />, {
route: '/websites/website-1/sessions?session=session-1',
});
expect(screen.getByTestId('session-delete-button')).toBeInTheDocument();
});
@@ -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 (
<LoadingPanel
@@ -46,7 +59,14 @@ export function SessionProfile({
{data && (
<Column gap>
{onClose && (
<Row justifyContent="flex-end">
<Row justifyContent="flex-end" gap="1">
{showDeleteButton && (
<SessionDeleteButton
websiteId={websiteId}
sessionId={sessionId}
onSave={onClose}
/>
)}
<Button onPress={onClose} variant="quiet">
<Icon>
<X />
+2
View File
@@ -1,3 +1,4 @@
import { isRelationalOnly } from '@/lib/db';
import { parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
@@ -16,6 +17,7 @@ export async function GET(request: Request) {
linksUrl: process.env.LINKS_URL,
pixelsUrl: process.env.PIXELS_URL,
privateMode: !!process.env.PRIVATE_MODE,
sessionDeletionEnabled: isRelationalOnly(),
telemetryDisabled: !!process.env.DISABLE_TELEMETRY,
trackerScriptName: process.env.TRACKER_SCRIPT_NAME,
updatesDisabled: !!process.env.DISABLE_UPDATES,
@@ -1,32 +1,49 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { isRelationalOnly } from '@/lib/db';
import { parseRequest } from '@/lib/request';
import { canViewWebsiteSection } from '@/permissions';
import { canDeleteWebsite, canViewWebsiteSection } from '@/permissions';
import { deleteSession } from '@/queries/prisma';
import { getLinkedDistinctIds, getLinkedSessionIds, getWebsiteSession } from '@/queries/sql';
import { GET } from './route';
import { DELETE, GET } from './route';
vi.mock('@/lib/db', () => ({
isRelationalOnly: vi.fn(),
}));
vi.mock('@/lib/request', () => ({
parseRequest: vi.fn(),
}));
vi.mock('@/permissions', () => ({
canDeleteWebsite: vi.fn(),
canViewWebsiteSection: vi.fn(),
}));
vi.mock('@/queries/prisma', () => ({
deleteSession: vi.fn(),
}));
vi.mock('@/queries/sql', () => ({
getLinkedDistinctIds: vi.fn(),
getLinkedSessionIds: vi.fn(),
getWebsiteSession: vi.fn(),
}));
const isRelationalOnlyMock = vi.mocked(isRelationalOnly);
const parseRequestMock = vi.mocked(parseRequest);
const canDeleteWebsiteMock = vi.mocked(canDeleteWebsite);
const canViewWebsiteSectionMock = vi.mocked(canViewWebsiteSection);
const deleteSessionMock = vi.mocked(deleteSession);
const getLinkedDistinctIdsMock = vi.mocked(getLinkedDistinctIds);
const getLinkedSessionIdsMock = vi.mocked(getLinkedSessionIds);
const getWebsiteSessionMock = vi.mocked(getWebsiteSession);
beforeEach(() => {
isRelationalOnlyMock.mockReset();
parseRequestMock.mockReset();
canDeleteWebsiteMock.mockReset();
canViewWebsiteSectionMock.mockReset();
deleteSessionMock.mockReset();
getLinkedDistinctIdsMock.mockReset();
getLinkedSessionIdsMock.mockReset();
getWebsiteSessionMock.mockReset();
@@ -35,6 +52,8 @@ beforeEach(() => {
test('GET returns not found when the session does not exist', async () => {
parseRequestMock.mockResolvedValue({ auth: {}, error: undefined });
canViewWebsiteSectionMock.mockResolvedValue(true);
isRelationalOnlyMock.mockReturnValue(true);
canDeleteWebsiteMock.mockResolvedValue(false);
getWebsiteSessionMock.mockResolvedValue(undefined);
const response = await GET(
@@ -51,3 +70,105 @@ test('GET returns not found when the session does not exist', async () => {
expect(getLinkedDistinctIdsMock).not.toHaveBeenCalled();
expect(getLinkedSessionIdsMock).not.toHaveBeenCalled();
});
test('GET includes canDelete when relational storage and delete permission are available', async () => {
parseRequestMock.mockResolvedValue({ auth: {}, error: undefined });
canViewWebsiteSectionMock.mockResolvedValue(true);
isRelationalOnlyMock.mockReturnValue(true);
canDeleteWebsiteMock.mockResolvedValue(true);
getWebsiteSessionMock.mockResolvedValue({
id: 'session-1',
distinctId: 'distinct-1',
});
getLinkedSessionIdsMock.mockResolvedValue([
{ sessionId: 'session-2', createdAt: '2026-07-24T00:00:00.000Z' },
]);
const response = await GET(
new Request('http://localhost/api/websites/website-1/sessions/session-1'),
{
params: Promise.resolve({ websiteId: 'website-1', sessionId: 'session-1' }),
},
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
id: 'session-1',
canDelete: true,
stitchedSessionCount: 2,
});
});
test('DELETE rejects session deletion for non-relational storage', async () => {
parseRequestMock.mockResolvedValue({ auth: {}, error: undefined });
isRelationalOnlyMock.mockReturnValue(false);
const response = await DELETE(
new Request('http://localhost/api/websites/website-1/sessions/session-1', { method: 'DELETE' }),
{
params: Promise.resolve({ websiteId: 'website-1', sessionId: 'session-1' }),
},
);
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'bad-request' },
});
expect(canDeleteWebsiteMock).not.toHaveBeenCalled();
expect(deleteSessionMock).not.toHaveBeenCalled();
});
test('DELETE returns unauthorized when the user cannot delete the website', async () => {
parseRequestMock.mockResolvedValue({ auth: {}, error: undefined });
isRelationalOnlyMock.mockReturnValue(true);
canDeleteWebsiteMock.mockResolvedValue(false);
const response = await DELETE(
new Request('http://localhost/api/websites/website-1/sessions/session-1', { method: 'DELETE' }),
{
params: Promise.resolve({ websiteId: 'website-1', sessionId: 'session-1' }),
},
);
expect(response.status).toBe(401);
expect(deleteSessionMock).not.toHaveBeenCalled();
});
test('DELETE returns not found when the session does not exist', async () => {
parseRequestMock.mockResolvedValue({ auth: {}, error: undefined });
isRelationalOnlyMock.mockReturnValue(true);
canDeleteWebsiteMock.mockResolvedValue(true);
deleteSessionMock.mockResolvedValue(null);
const response = await DELETE(
new Request('http://localhost/api/websites/website-1/sessions/missing-session', {
method: 'DELETE',
}),
{
params: Promise.resolve({ websiteId: 'website-1', sessionId: 'missing-session' }),
},
);
expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'not-found', status: 404 },
});
});
test('DELETE removes the session when the request is valid', async () => {
parseRequestMock.mockResolvedValue({ auth: {}, error: undefined });
isRelationalOnlyMock.mockReturnValue(true);
canDeleteWebsiteMock.mockResolvedValue(true);
deleteSessionMock.mockResolvedValue({ id: 'session-1' });
const response = await DELETE(
new Request('http://localhost/api/websites/website-1/sessions/session-1', { method: 'DELETE' }),
{
params: Promise.resolve({ websiteId: 'website-1', sessionId: 'session-1' }),
},
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ ok: true });
expect(deleteSessionMock).toHaveBeenCalledWith('website-1', 'session-1');
});
@@ -1,6 +1,8 @@
import { isRelationalOnly } from '@/lib/db';
import { parseRequest } from '@/lib/request';
import { json, notFound, unauthorized } from '@/lib/response';
import { canViewWebsiteSection } from '@/permissions';
import { badRequest, json, notFound, ok, unauthorized } from '@/lib/response';
import { canDeleteWebsite, canViewWebsiteSection } from '@/permissions';
import { deleteSession } from '@/queries/prisma';
import { getLinkedDistinctIds, getLinkedSessionIds, getWebsiteSession } from '@/queries/sql';
export async function GET(
@@ -14,6 +16,7 @@ export async function GET(
}
const { websiteId, sessionId } = await params;
const canDelete = isRelationalOnly() && (await canDeleteWebsite(auth, websiteId));
if (
!(await canViewWebsiteSection(auth, websiteId, ['sessions', 'events', 'realtime', 'revenue']))
@@ -49,6 +52,36 @@ export async function GET(
return json({
...data,
canDelete,
stitchedSessionCount,
});
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ websiteId: string; sessionId: string }> },
) {
const { auth, error } = await parseRequest(request);
if (error) {
return error();
}
if (!isRelationalOnly()) {
return badRequest({ message: 'Session deletion is only available with relational storage.' });
}
const { websiteId, sessionId } = await params;
if (!(await canDeleteWebsite(auth, websiteId))) {
return unauthorized();
}
const deletedSession = await deleteSession(websiteId, sessionId);
if (!deletedSession) {
return notFound();
}
return ok();
}
+1
View File
@@ -8,6 +8,7 @@ export type Config = {
linksUrl?: string;
pixelsUrl?: string;
privateMode: boolean;
sessionDeletionEnabled: boolean;
telemetryDisabled: boolean;
trackerScriptName?: string;
updatesDisabled: boolean;
+23
View File
@@ -81,6 +81,29 @@ describe('runQuery', () => {
});
});
describe('isRelationalOnly', () => {
test('returns true for a postgresql database without clickhouse', () => {
vi.stubEnv('CLICKHOUSE_URL', '');
vi.stubEnv('DATABASE_URL', 'postgresql://user:pass@localhost:5432/umami');
expect(db.isRelationalOnly()).toBe(true);
});
test('returns false when clickhouse is enabled', () => {
vi.stubEnv('CLICKHOUSE_URL', 'http://localhost:8123');
vi.stubEnv('DATABASE_URL', 'postgresql://user:pass@localhost:5432/umami');
expect(db.isRelationalOnly()).toBe(false);
});
test('returns false for unsupported non-relational databases', () => {
vi.stubEnv('CLICKHOUSE_URL', '');
vi.stubEnv('DATABASE_URL', 'mysql://user:pass@localhost:3306/umami');
expect(db.isRelationalOnly()).toBe(false);
});
});
describe('BigInt.prototype.toJSON', () => {
test('serializes bigint values as numbers', () => {
expect((BigInt(123) as any).toJSON()).toBe(123);
+4
View File
@@ -19,6 +19,10 @@ export function getDatabaseType(url = process.env.DATABASE_URL) {
return type;
}
export function isRelationalOnly() {
return !process.env.CLICKHOUSE_URL && getDatabaseType() === POSTGRESQL;
}
export async function runQuery(queries: any) {
if (process.env.CLICKHOUSE_URL) {
if (queries[KAFKA]) {
+1
View File
@@ -3,6 +3,7 @@ export * from './link';
export * from './pixel';
export * from './report';
export * from './segment';
export * from './session';
export * from './sessionReplay';
export * from './share';
export * from './team';
+121
View File
@@ -0,0 +1,121 @@
import prisma from '@/lib/prisma';
export async function deleteSession(
websiteId: string,
sessionId: string,
): Promise<{ id: string } | null> {
const transaction = prisma.transaction as <T>(input: (tx: any) => Promise<T>) => Promise<T>;
return transaction(async tx => {
const session = await tx.session.findFirst({
where: {
id: sessionId,
websiteId,
},
select: {
id: true,
},
});
if (!session) {
return null;
}
const websiteEvents = await tx.websiteEvent.findMany({
where: {
websiteId,
sessionId,
},
select: {
id: true,
visitId: true,
},
});
const sessionReplays = await tx.sessionReplay.findMany({
where: {
websiteId,
sessionId,
},
select: {
visitId: true,
},
});
const eventIds = websiteEvents.map(({ id }) => id);
const visitIds = Array.from(
new Set([...websiteEvents, ...sessionReplays].map(({ visitId }) => visitId)),
);
if (eventIds.length) {
await tx.eventData.deleteMany({
where: {
websiteEventId: {
in: eventIds,
},
},
});
}
if (visitIds.length) {
await tx.sessionReplaySaved.deleteMany({
where: {
websiteId,
visitId: {
in: visitIds,
},
},
});
}
await tx.sessionReplay.deleteMany({
where: {
websiteId,
sessionId,
},
});
await tx.heatmapEvent.deleteMany({
where: {
websiteId,
sessionId,
},
});
await tx.revenue.deleteMany({
where: {
websiteId,
sessionId,
},
});
await tx.sessionData.deleteMany({
where: {
websiteId,
sessionId,
},
});
await tx.sessionLink.deleteMany({
where: {
websiteId,
sessionId,
},
});
await tx.websiteEvent.deleteMany({
where: {
websiteId,
sessionId,
},
});
await tx.session.delete({
where: {
id: sessionId,
},
});
return session;
});
}