Fix multiple issues: export empty datasets, reset large data, geo-location tracking, and timezone consistency

This commit is contained in:
AYUSH PANDEY 2025-11-08 23:09:49 +05:30
parent 6ba9c1c40c
commit 372d1d86f4
9 changed files with 441 additions and 80 deletions

View file

@ -0,0 +1,60 @@
import { GET } from '../route';
// Mock the dependencies
jest.mock('@/lib/request', () => ({
getQueryFilters: jest.fn().mockResolvedValue({}),
parseRequest: jest.fn().mockResolvedValue({
auth: { userId: 'test-user' },
query: {},
error: null,
}),
}));
jest.mock('@/lib/response', () => ({
unauthorized: jest.fn().mockReturnValue({ status: 401, json: () => {} }),
json: jest.fn().mockImplementation((data) => ({ status: 200, json: () => Promise.resolve(data) })),
}));
jest.mock('@/permissions', () => ({
canViewWebsite: jest.fn().mockResolvedValue(true),
}));
jest.mock('@/queries/sql', () => ({
getEventMetrics: jest.fn().mockResolvedValue([]),
getPageviewMetrics: jest.fn().mockResolvedValue([]),
getSessionMetrics: jest.fn().mockResolvedValue([]),
}));
describe('Export API Route', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return error when no data is available', async () => {
const request = new Request('http://localhost:3000');
const params = Promise.resolve({ websiteId: 'test-website' });
const response = await GET(request, { params });
const data = await response.json();
expect(data).toEqual({ error: 'no_data' });
});
it('should return zip data when data is available', async () => {
// Mock some data being returned
const mockData = [{ id: 1, name: 'Test' }];
require('@/queries/sql').getEventMetrics.mockResolvedValueOnce(mockData);
require('@/queries/sql').getPageviewMetrics.mockResolvedValueOnce([]);
require('@/queries/sql').getSessionMetrics.mockResolvedValueOnce([]);
const request = new Request('http://localhost:3000');
const params = Promise.resolve({ websiteId: 'test-website' });
const response = await GET(request, { params });
const data = await response.json();
expect(data).toHaveProperty('zip');
expect(typeof data.zip).toBe('string');
});
});

View file

@ -40,6 +40,21 @@ export async function GET(
getSessionMetrics(websiteId, { type: 'country' }, filters),
]);
// Check if all datasets are empty
const hasData = [
events,
pages,
referrers,
browsers,
os,
devices,
countries
].some(dataset => dataset && dataset.length > 0);
if (!hasData) {
return json({ error: 'no_data' });
}
const zip = new JSZip();
const parse = (data: any) => {

View file

@ -1,5 +1,5 @@
import { useState } from 'react';
import { Icon, Tooltip, TooltipTrigger, LoadingButton } from '@umami/react-zen';
import { Icon, Tooltip, TooltipTrigger, LoadingButton, useToast } from '@umami/react-zen';
import { Download } from '@/components/icons';
import { useMessages, useApi } from '@/components/hooks';
import { useSearchParams } from 'next/navigation';
@ -7,7 +7,8 @@ import { useDateParameters } from '@/components/hooks/useDateParameters';
import { useFilterParameters } from '@/components/hooks/useFilterParameters';
export function ExportButton({ websiteId }: { websiteId: string }) {
const { formatMessage, labels } = useMessages();
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const date = useDateParameters();
const filters = useFilterParameters();
@ -17,16 +18,29 @@ export function ExportButton({ websiteId }: { websiteId: string }) {
const handleClick = async () => {
setIsLoading(true);
const { zip } = await get(`/websites/${websiteId}/export`, {
...date,
...filters,
...searchParams,
format: 'json',
});
try {
const response = await get(`/websites/${websiteId}/export`, {
...date,
...filters,
...searchParams,
format: 'json',
});
await loadZip(zip);
// Check if there's an error indicating no data
if (response.error === 'no_data') {
toast(formatMessage(messages.noDataAvailable));
setIsLoading(false);
return;
}
setIsLoading(false);
// Proceed with export if there's data
await loadZip(response.zip);
} catch (error) {
// Handle any other errors
toast(formatMessage(messages.error));
} finally {
setIsLoading(false);
}
};
return (

View file

@ -1,28 +1,113 @@
import { getIpAddress } from '../ip';
import { getLocation } from '../detect';
import isLocalhost from 'is-localhost-ip';
import maxmind from 'maxmind';
const IP = '127.0.0.1';
const BAD_IP = '127.127.127.127';
// Mock the dependencies
jest.mock('is-localhost-ip', () => jest.fn());
jest.mock('maxmind', () => ({
open: jest.fn(),
}));
test('getIpAddress: Custom header', () => {
process.env.CLIENT_IP_HEADER = 'x-custom-ip-header';
describe('getLocation', () => {
beforeEach(() => {
jest.clearAllMocks();
delete global.maxmind;
});
expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP);
});
it('should return null for localhost IPs', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(true);
const result = await getLocation('127.0.0.1', new Headers(), false);
expect(result).toBeNull();
});
test('getIpAddress: CloudFlare header', () => {
expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP);
});
it('should return location data from provider headers', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(false);
const headers = new Headers();
headers.set('cf-ipcountry', 'KR');
headers.set('cf-region-code', '11');
headers.set('cf-ipcity', 'Seoul');
const result = await getLocation('1.2.3.4', headers, false);
expect(result).toEqual({
country: 'KR',
region: 'KR-11',
city: 'Seoul',
});
});
test('getIpAddress: Standard header', () => {
expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP);
});
it('should return location data from MaxMind database', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(false);
const mockMaxmindDb = {
get: jest.fn().mockReturnValue({
country: { iso_code: 'KR' },
subdivisions: [{ iso_code: '11' }],
city: { names: { en: 'Seoul' } },
}),
};
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toEqual({
country: 'KR',
region: 'KR-11',
city: 'Seoul',
});
});
test('getIpAddress: CloudFlare header is lower priority than standard header', () => {
expect(getIpAddress(new Headers({ 'cf-connecting-ip': BAD_IP, 'x-forwarded-for': IP }))).toEqual(
IP,
);
});
it('should try multiple sources for country code', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(false);
const mockMaxmindDb = {
get: jest.fn().mockReturnValue({
registered_country: { iso_code: 'KR' },
subdivisions: [{ iso_code: '11' }],
city: { names: { en: 'Seoul' } },
}),
};
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toEqual({
country: 'KR',
region: 'KR-11',
city: 'Seoul',
});
});
test('getIpAddress: No header', () => {
expect(getIpAddress(new Headers())).toEqual(null);
});
it('should return null if no country code is available', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(false);
const mockMaxmindDb = {
get: jest.fn().mockReturnValue({
// No country information
subdivisions: [{ iso_code: '11' }],
city: { names: { en: 'Seoul' } },
}),
};
(maxmind.open as jest.Mock).mockResolvedValue(mockMaxmindDb);
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toBeNull();
});
it('should handle errors gracefully', async () => {
(isLocalhost as jest.Mock).mockResolvedValue(false);
(maxmind.open as jest.Mock).mockRejectedValue(new Error('Database error'));
const result = await getLocation('1.2.3.4', new Headers(), false);
expect(result).toBeNull();
});
});

View file

@ -84,27 +84,46 @@ export async function getLocation(ip: string = '', headers: Headers, hasPayloadI
}
// Database lookup
if (!globalThis[MAXMIND]) {
const dir = path.join(process.cwd(), 'geo');
try {
if (!globalThis[MAXMIND]) {
const dir = path.join(process.cwd(), 'geo');
globalThis[MAXMIND] = await maxmind.open(
process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
);
}
const result = globalThis[MAXMIND]?.get(stripPort(ip));
if (result) {
const country = result.country?.iso_code ?? result?.registered_country?.iso_code;
const region = result.subdivisions?.[0]?.iso_code;
const city = result.city?.names?.en;
return {
country,
region: getRegionCode(country, region),
city,
};
globalThis[MAXMIND] = await maxmind.open(
process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'),
);
}
// Strip port from IP address before lookup
const cleanIp = stripPort(ip);
const result = globalThis[MAXMIND]?.get(cleanIp);
if (result) {
// Try multiple sources for country code to ensure we get a value
const country =
result.country?.iso_code ||
result.registered_country?.iso_code ||
result.represented_country?.iso_code ||
result.continent?.code;
const region = result.subdivisions?.[0]?.iso_code;
const city = result.city?.names?.en;
// Only return location data if we have at least a country code
if (country) {
return {
country,
region: getRegionCode(country, region),
city,
};
}
}
} catch (error) {
// Log error but don't crash the application
console.error('Geo-location lookup failed:', error);
}
// Return null if no location data could be determined
return null;
}
export async function getClientInfo(request: Request, payload: Record<string, any>) {

View file

@ -0,0 +1,109 @@
import { resetWebsite } from '../website';
// Mock the prisma client
jest.mock('@/lib/prisma', () => ({
default: {
client: {
eventData: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
sessionData: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
websiteEvent: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
session: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
website: {
update: jest.fn().mockResolvedValue({ id: 'test-website' }),
},
},
},
}));
jest.mock('@/lib/redis', () => ({
default: {
client: {
set: jest.fn(),
del: jest.fn(),
},
},
}));
describe('resetWebsite', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should reset website data in batches to avoid transaction timeouts', async () => {
const websiteId = 'test-website';
// Mock deleteMany to return 10000 records deleted on first call, then 0
require('@/lib/prisma').default.client.eventData.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.sessionData.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.websiteEvent.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.session.deleteMany
.mockResolvedValueOnce({ count: 10000 })
.mockResolvedValueOnce({ count: 0 });
await resetWebsite(websiteId);
// Verify that deleteMany was called with the correct parameters
expect(require('@/lib/prisma').default.client.eventData.deleteMany).toHaveBeenCalledWith({
where: { websiteId },
take: 10000,
});
// Verify that the website update was called
expect(require('@/lib/prisma').default.client.website.update).toHaveBeenCalledWith({
where: { id: websiteId },
data: {
resetAt: expect.any(Date),
},
});
});
it('should handle single batch deletion when data is small', async () => {
const websiteId = 'test-website';
// Mock deleteMany to return 0 records deleted (no more data)
require('@/lib/prisma').default.client.eventData.deleteMany
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.sessionData.deleteMany
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.websiteEvent.deleteMany
.mockResolvedValueOnce({ count: 0 });
require('@/lib/prisma').default.client.session.deleteMany
.mockResolvedValueOnce({ count: 0 });
await resetWebsite(websiteId);
// Verify that deleteMany was called once for each model
expect(require('@/lib/prisma').default.client.eventData.deleteMany).toHaveBeenCalledTimes(1);
expect(require('@/lib/prisma').default.client.sessionData.deleteMany).toHaveBeenCalledTimes(1);
expect(require('@/lib/prisma').default.client.websiteEvent.deleteMany).toHaveBeenCalledTimes(1);
expect(require('@/lib/prisma').default.client.session.deleteMany).toHaveBeenCalledTimes(1);
// Verify that the website update was called
expect(require('@/lib/prisma').default.client.website.update).toHaveBeenCalledWith({
where: { id: websiteId },
data: {
resetAt: expect.any(Date),
},
});
});
});

View file

@ -132,38 +132,41 @@ export async function updateWebsite(
}
export async function resetWebsite(websiteId: string) {
const { client, transaction } = prisma;
const { client } = prisma;
const cloudMode = !!process.env.CLOUD_MODE;
return transaction([
client.eventData.deleteMany({
where: { websiteId },
}),
client.sessionData.deleteMany({
where: { websiteId },
}),
client.websiteEvent.deleteMany({
where: { websiteId },
}),
client.session.deleteMany({
where: { websiteId },
}),
client.website.update({
where: { id: websiteId },
data: {
resetAt: new Date(),
},
}),
]).then(async data => {
if (cloudMode) {
await redis.client.set(
`website:${websiteId}`,
data.find(website => website.id),
);
}
// For large datasets, we need to delete data in chunks to avoid transaction timeouts
// We'll delete data in batches of 10000 records at a time
const deleteInBatches = async (model: any, where: any) => {
let deletedCount;
do {
const result = await model.deleteMany({
where,
take: 10000, // Limit to 10000 records per batch
});
deletedCount = result.count;
} while (deletedCount === 10000); // Continue until we delete less than 10000 records
};
return data;
// Delete data in batches to avoid transaction timeouts
await deleteInBatches(client.eventData, { websiteId });
await deleteInBatches(client.sessionData, { websiteId });
await deleteInBatches(client.websiteEvent, { websiteId });
await deleteInBatches(client.session, { websiteId });
// Update the website reset timestamp
const data = await client.website.update({
where: { id: websiteId },
data: {
resetAt: new Date(),
},
});
if (cloudMode) {
await redis.client.set(`website:${websiteId}`, data);
}
return data;
}
export async function deleteWebsite(websiteId: string) {

View file

@ -0,0 +1,50 @@
import { getRealtimeData } from '../getRealtimeData';
// Mock the dependencies
jest.mock('../getRealtimeActivity', () => ({
getRealtimeActivity: jest.fn().mockResolvedValue([]),
}));
jest.mock('../pageviews/getPageviewStats', () => ({
getPageviewStats: jest.fn().mockResolvedValue([]),
}));
jest.mock('../sessions/getSessionStats', () => ({
getSessionStats: jest.fn().mockResolvedValue([]),
}));
describe('getRealtimeData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should pass timezone parameter to stats functions', async () => {
const websiteId = 'test-website';
const filters = { timezone: 'America/New_York' };
await getRealtimeData(websiteId, filters);
// Verify that getPageviewStats was called with the timezone
expect(require('../pageviews/getPageviewStats').getPageviewStats)
.toHaveBeenCalledWith(websiteId, { ...filters, timezone: 'America/New_York' });
// Verify that getSessionStats was called with the timezone
expect(require('../sessions/getSessionStats').getSessionStats)
.toHaveBeenCalledWith(websiteId, { ...filters, timezone: 'America/New_York' });
});
it('should default to UTC timezone when not provided', async () => {
const websiteId = 'test-website';
const filters = {};
await getRealtimeData(websiteId, filters);
// Verify that getPageviewStats was called with default UTC timezone
expect(require('../pageviews/getPageviewStats').getPageviewStats)
.toHaveBeenCalledWith(websiteId, { timezone: 'utc' });
// Verify that getSessionStats was called with default UTC timezone
expect(require('../sessions/getSessionStats').getSessionStats)
.toHaveBeenCalledWith(websiteId, { timezone: 'utc' });
});
});

View file

@ -14,10 +14,16 @@ function increment(data: object, key: string) {
}
export async function getRealtimeData(websiteId: string, filters: QueryFilters) {
// Extract timezone from filters to ensure consistent timezone usage
const { timezone = 'utc' } = filters;
// Pass timezone to the stats functions to ensure consistent time formatting
const statsFilters = { ...filters, timezone };
const [activity, pageviews, sessions] = await Promise.all([
getRealtimeActivity(websiteId, filters),
getPageviewStats(websiteId, filters),
getSessionStats(websiteId, filters),
getPageviewStats(websiteId, statsFilters),
getSessionStats(websiteId, statsFilters),
]);
const uniques = new Set();