feat(api): add insights endpoints
This commit is contained in:
178
apps/api/src/controllers/insights.controller.ts
Normal file
178
apps/api/src/controllers/insights.controller.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
import { getDefaultIntervalByDates } from '@openpanel/constants';
|
||||
import {
|
||||
eventBuffer,
|
||||
getChartStartEndDate,
|
||||
getSettingsForProject,
|
||||
overviewService,
|
||||
} from '@openpanel/db';
|
||||
import { zChartEventFilter, zRange } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zGetMetricsQuery = z.object({
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange.default('7d'),
|
||||
filters: z.array(zChartEventFilter).default([]),
|
||||
});
|
||||
// Website stats - main metrics overview
|
||||
export async function getMetrics(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Querystring: z.infer<typeof zGetMetricsQuery>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||
const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query));
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid query parameters',
|
||||
details: parsed.error,
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = getChartStartEndDate(parsed.data, timezone);
|
||||
|
||||
reply.send(
|
||||
await overviewService.getMetrics({
|
||||
projectId: request.params.projectId,
|
||||
filters: parsed.data.filters,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day',
|
||||
timezone,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Live visitors (real-time)
|
||||
export async function getLiveVisitors(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
reply.send({
|
||||
visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId),
|
||||
});
|
||||
}
|
||||
|
||||
export const zGetTopPagesQuery = z.object({
|
||||
filters: z.array(zChartEventFilter).default([]),
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange.default('7d'),
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().default(10),
|
||||
});
|
||||
|
||||
// Page views with top pages
|
||||
export async function getPages(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
Querystring: z.infer<typeof zGetTopPagesQuery>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(request.query, timezone);
|
||||
const parsed = zGetTopPagesQuery.safeParse(parseQueryString(request.query));
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid query parameters',
|
||||
details: parsed.error,
|
||||
});
|
||||
}
|
||||
|
||||
return overviewService.getTopPages({
|
||||
projectId: request.params.projectId,
|
||||
filters: parsed.data.filters,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
});
|
||||
}
|
||||
|
||||
const zGetOverviewGenericQuery = z.object({
|
||||
filters: z.array(zChartEventFilter).default([]),
|
||||
startDate: z.string().nullish(),
|
||||
endDate: z.string().nullish(),
|
||||
range: zRange.default('7d'),
|
||||
column: z.enum([
|
||||
// Referrers
|
||||
'referrer',
|
||||
'referrer_name',
|
||||
'referrer_type',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
// Geo
|
||||
'region',
|
||||
'country',
|
||||
'city',
|
||||
// Device
|
||||
'device',
|
||||
'brand',
|
||||
'model',
|
||||
'browser',
|
||||
'browser_version',
|
||||
'os',
|
||||
'os_version',
|
||||
]),
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().default(10),
|
||||
});
|
||||
|
||||
export function getOverviewGeneric(
|
||||
column: z.infer<typeof zGetOverviewGenericQuery>['column'],
|
||||
) {
|
||||
return async (
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string; key: string };
|
||||
Querystring: z.infer<typeof zGetOverviewGenericQuery>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(
|
||||
request.query,
|
||||
timezone,
|
||||
);
|
||||
const parsed = zGetOverviewGenericQuery.safeParse({
|
||||
...parseQueryString(request.query),
|
||||
column,
|
||||
});
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid query parameters',
|
||||
details: parsed.error,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Implement overview generic endpoint
|
||||
reply.send(
|
||||
await overviewService.getTopGeneric({
|
||||
column,
|
||||
projectId: request.params.projectId,
|
||||
filters: parsed.data.filters,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import aiRouter from './routes/ai.router';
|
||||
import eventRouter from './routes/event.router';
|
||||
import exportRouter from './routes/export.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
@@ -169,6 +170,7 @@ const startServer = async () => {
|
||||
instance.register(profileRouter, { prefix: '/profile' });
|
||||
instance.register(exportRouter, { prefix: '/export' });
|
||||
instance.register(importRouter, { prefix: '/import' });
|
||||
instance.register(insightsRouter, { prefix: '/insights' });
|
||||
instance.register(trackRouter, { prefix: '/track' });
|
||||
instance.get('/healthcheck', healthcheck);
|
||||
instance.get('/healthcheck/queue', healthcheckQueue);
|
||||
|
||||
89
apps/api/src/routes/insights.router.ts
Normal file
89
apps/api/src/routes/insights.router.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as controller from '@/controllers/insights.controller';
|
||||
import { validateExportRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const insightsRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
fastify,
|
||||
max: 100,
|
||||
timeWindow: '10 seconds',
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
try {
|
||||
const client = await validateExportRequest(req.headers);
|
||||
req.client = client;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return reply.status(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Client ID seems to be malformed',
|
||||
});
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: e.message });
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Website stats - main metrics overview
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/:projectId/metrics',
|
||||
handler: controller.getMetrics,
|
||||
});
|
||||
|
||||
// Live visitors (real-time)
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/:projectId/live',
|
||||
handler: controller.getLiveVisitors,
|
||||
});
|
||||
|
||||
// Page views with top pages
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/:projectId/pages',
|
||||
handler: controller.getPages,
|
||||
});
|
||||
|
||||
const overviewMetrics = [
|
||||
'referrer_name',
|
||||
'referrer',
|
||||
'referrer_type',
|
||||
'utm_source',
|
||||
'utm_medium',
|
||||
'utm_campaign',
|
||||
'utm_term',
|
||||
'utm_content',
|
||||
'device',
|
||||
'browser',
|
||||
'browser_version',
|
||||
'os',
|
||||
'os_version',
|
||||
'brand',
|
||||
'model',
|
||||
'country',
|
||||
'region',
|
||||
'city',
|
||||
] as const;
|
||||
|
||||
overviewMetrics.forEach((key) => {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: `/:projectId/${key}`,
|
||||
handler: controller.getOverviewGeneric(key),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export default insightsRouter;
|
||||
Reference in New Issue
Block a user