Merge branch 'dev' of https://github.com/umami-software/umami into dev
This commit is contained in:
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 />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type Config = {
|
||||
linksUrl?: string;
|
||||
pixelsUrl?: string;
|
||||
privateMode: boolean;
|
||||
sessionDeletionEnabled: boolean;
|
||||
telemetryDisabled: boolean;
|
||||
trackerScriptName?: string;
|
||||
updatesDisabled: boolean;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user