add charts to export api
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
"@openpanel/queue": "workspace:*",
|
"@openpanel/queue": "workspace:*",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"@openpanel/trpc": "workspace:*",
|
"@openpanel/trpc": "workspace:*",
|
||||||
|
"@openpanel/validation": "workspace:*",
|
||||||
"@trpc/server": "^10.45.1",
|
"@trpc/server": "^10.45.1",
|
||||||
"fastify": "^4.25.2",
|
"fastify": "^4.25.2",
|
||||||
"fastify-metrics": "^11.0.0",
|
"fastify-metrics": "^11.0.0",
|
||||||
@@ -35,7 +36,8 @@
|
|||||||
"sqlstring": "^2.3.3",
|
"sqlstring": "^2.3.3",
|
||||||
"superjson": "^1.13.3",
|
"superjson": "^1.13.3",
|
||||||
"ua-parser-js": "^1.0.37",
|
"ua-parser-js": "^1.0.37",
|
||||||
"url-metadata": "^4.1.0"
|
"url-metadata": "^4.1.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@openpanel/eslint-config": "workspace:*",
|
"@openpanel/eslint-config": "workspace:*",
|
||||||
|
|||||||
@@ -1,42 +1,39 @@
|
|||||||
|
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { GetEventListOptions } from '@openpanel/db';
|
import type { GetEventListOptions } from '@openpanel/db';
|
||||||
import { ClientType, db, getEventList, getEventsCount } from '@openpanel/db';
|
import { ClientType, db, getEventList, getEventsCount } from '@openpanel/db';
|
||||||
|
import { getChart } from '@openpanel/trpc/src/routers/chart';
|
||||||
|
import { zChartInput } from '@openpanel/validation';
|
||||||
|
|
||||||
type EventsQuery = {
|
async function getProjectId(
|
||||||
project_id?: string;
|
|
||||||
event?: string | string[];
|
|
||||||
start?: string;
|
|
||||||
end?: string;
|
|
||||||
page?: string;
|
|
||||||
limit?: string;
|
|
||||||
};
|
|
||||||
export async function events(
|
|
||||||
request: FastifyRequest<{
|
request: FastifyRequest<{
|
||||||
Querystring: EventsQuery;
|
Querystring: {
|
||||||
|
project_id?: string;
|
||||||
|
projectId?: string;
|
||||||
|
};
|
||||||
}>,
|
}>,
|
||||||
reply: FastifyReply
|
reply: FastifyReply
|
||||||
) {
|
) {
|
||||||
const query = request.query;
|
let projectId = request.query.projectId || request.query.project_id;
|
||||||
const limit = parseInt(query.limit || '50', 10);
|
|
||||||
const page = parseInt(query.page || '1', 10);
|
|
||||||
|
|
||||||
if (query.project_id) {
|
if (projectId) {
|
||||||
if (
|
if (
|
||||||
request.client?.type === ClientType.read &&
|
request.client?.type === ClientType.read &&
|
||||||
request.client?.projectId !== query.project_id
|
request.client?.projectId !== projectId
|
||||||
) {
|
) {
|
||||||
reply.status(403).send({
|
reply.status(403).send({
|
||||||
error: 'Forbidden',
|
error: 'Forbidden',
|
||||||
message: 'You do not have access to this project',
|
message: 'You do not have access to this project',
|
||||||
});
|
});
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: {
|
where: {
|
||||||
organizationSlug: request.client?.organizationSlug,
|
organizationSlug: request.client?.organizationSlug,
|
||||||
id: query.project_id,
|
id: projectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,29 +42,64 @@ export async function events(
|
|||||||
error: 'Not Found',
|
error: 'Not Found',
|
||||||
message: 'Project not found',
|
message: 'Project not found',
|
||||||
});
|
});
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectId = query.project_id ?? request.client?.projectId;
|
if (!projectId && request.client?.projectId) {
|
||||||
|
projectId = request.client?.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
reply.status(400).send({
|
reply.status(400).send({
|
||||||
error: 'Bad Request',
|
error: 'Bad Request',
|
||||||
message: 'project_id is required',
|
message: 'project_id is required',
|
||||||
});
|
});
|
||||||
return;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsScheme = z.object({
|
||||||
|
project_id: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
event: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
|
start: z.coerce.string().optional(),
|
||||||
|
end: z.coerce.string().optional(),
|
||||||
|
page: z.coerce.number().optional().default(1),
|
||||||
|
limit: z.coerce.number().optional().default(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function events(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Querystring: z.infer<typeof eventsScheme>;
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const query = eventsScheme.safeParse(request.query);
|
||||||
|
|
||||||
|
if (query.success === false) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Invalid query parameters',
|
||||||
|
details: query.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = await getProjectId(request, reply);
|
||||||
|
const limit = query.data.limit;
|
||||||
|
const page = Math.max(query.data.page, 1);
|
||||||
const take = Math.max(Math.min(limit, 50), 1);
|
const take = Math.max(Math.min(limit, 50), 1);
|
||||||
const cursor = page - 1;
|
const cursor = page - 1;
|
||||||
const options: GetEventListOptions = {
|
const options: GetEventListOptions = {
|
||||||
projectId,
|
projectId,
|
||||||
events: (Array.isArray(query.event) ? query.event : [query.event]).filter(
|
events: (Array.isArray(query.data.event)
|
||||||
(s): s is string => typeof s === 'string'
|
? query.data.event
|
||||||
),
|
: [query.data.event]
|
||||||
startDate: query.start ? new Date(query.start) : undefined,
|
).filter((s): s is string => typeof s === 'string'),
|
||||||
endDate: query.end ? new Date(query.end) : undefined,
|
startDate: query.data.start ? new Date(query.data.start) : undefined,
|
||||||
|
endDate: query.data.end ? new Date(query.data.end) : undefined,
|
||||||
cursor,
|
cursor,
|
||||||
take,
|
take,
|
||||||
meta: false,
|
meta: false,
|
||||||
@@ -89,3 +121,39 @@ export async function events(
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chartSchemeFull = zChartInput.pick({
|
||||||
|
events: true,
|
||||||
|
breakdowns: true,
|
||||||
|
projectId: true,
|
||||||
|
interval: true,
|
||||||
|
range: true,
|
||||||
|
previous: true,
|
||||||
|
startDate: true,
|
||||||
|
endDate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function charts(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Querystring: Record<string, string>;
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
|
||||||
|
|
||||||
|
if (query.success === false) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'Invalid query parameters',
|
||||||
|
details: query.error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return getChart({
|
||||||
|
...query.data,
|
||||||
|
name: 'export-api',
|
||||||
|
metric: 'sum',
|
||||||
|
lineType: 'monotone',
|
||||||
|
chartType: 'linear',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
|||||||
url: '/events',
|
url: '/events',
|
||||||
handler: controller.events,
|
handler: controller.events,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/charts',
|
||||||
|
handler: controller.charts,
|
||||||
|
});
|
||||||
done();
|
done();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
apps/api/src/utils/parse-zod-query-string.ts
Normal file
19
apps/api/src/utils/parse-zod-query-string.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { getSafeJson } from '@openpanel/common';
|
||||||
|
|
||||||
|
export const parseQueryString = (obj: Record<string, any>): any => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).map(([k, v]) => {
|
||||||
|
if (typeof v === 'object') return [k, parseQueryString(v)];
|
||||||
|
if (!isNaN(parseFloat(v))) return [k, parseFloat(v)];
|
||||||
|
if (v === 'true') return [k, true];
|
||||||
|
if (v === 'false') return [k, false];
|
||||||
|
if (typeof v === 'string') {
|
||||||
|
if (getSafeJson(v) !== null) {
|
||||||
|
return [k, getSafeJson(v)];
|
||||||
|
}
|
||||||
|
return [k, v];
|
||||||
|
}
|
||||||
|
return [k, null];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
differenceInMilliseconds,
|
differenceInMilliseconds,
|
||||||
endOfDay,
|
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
endOfYear,
|
endOfYear,
|
||||||
formatISO,
|
formatISO,
|
||||||
startOfDay,
|
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
startOfYear,
|
startOfYear,
|
||||||
subDays,
|
subDays,
|
||||||
@@ -14,14 +12,13 @@ import {
|
|||||||
subYears,
|
subYears,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import * as mathjs from 'mathjs';
|
import * as mathjs from 'mathjs';
|
||||||
import { repeat, reverse, sort } from 'ramda';
|
import { repeat, reverse } from 'ramda';
|
||||||
import { escape } from 'sqlstring';
|
import { escape } from 'sqlstring';
|
||||||
|
|
||||||
import { completeTimeline, round } from '@openpanel/common';
|
import { completeTimeline, round } from '@openpanel/common';
|
||||||
import { alphabetIds, NOT_SET_VALUE } from '@openpanel/constants';
|
import { alphabetIds, NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import {
|
import {
|
||||||
chQuery,
|
chQuery,
|
||||||
convertClickhouseDateToJs,
|
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
formatClickhouseDate,
|
formatClickhouseDate,
|
||||||
getChartSql,
|
getChartSql,
|
||||||
@@ -44,7 +41,7 @@ export interface ResultItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getEventLegend(event: IChartEvent) {
|
function getEventLegend(event: IChartEvent) {
|
||||||
return event.displayName ?? `${event.name} (${event.id})`;
|
return event.displayName ?? event.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withFormula(
|
export function withFormula(
|
||||||
@@ -68,12 +65,16 @@ export function withFormula(
|
|||||||
|
|
||||||
if (events.length === 1) {
|
if (events.length === 1) {
|
||||||
return series.map((serie) => {
|
return series.map((serie) => {
|
||||||
|
if (!serie.event.id) {
|
||||||
|
return serie;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...serie,
|
...serie,
|
||||||
data: serie.data.map((item) => {
|
data: serie.data.map((item) => {
|
||||||
serie.event.id;
|
serie.event.id;
|
||||||
const scope = {
|
const scope = {
|
||||||
[serie.event.id]: item?.count ?? 0,
|
[serie.event.id ?? '']: item?.count ?? 0,
|
||||||
};
|
};
|
||||||
const count = mathjs
|
const count = mathjs
|
||||||
.parse(formula)
|
.parse(formula)
|
||||||
@@ -97,6 +98,10 @@ export function withFormula(
|
|||||||
...series[0],
|
...series[0],
|
||||||
data: series[0].data.map((item, dIndex) => {
|
data: series[0].data.map((item, dIndex) => {
|
||||||
const scope = series.reduce((acc, item) => {
|
const scope = series.reduce((acc, item) => {
|
||||||
|
if (!item.event.id) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[item.event.id]: item.data[dIndex]?.count ?? 0,
|
[item.event.id]: item.data[dIndex]?.count ?? 0,
|
||||||
|
|||||||
@@ -205,6 +205,11 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getChart(input);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function getChart(input: IChartInput) {
|
||||||
const currentPeriod = getChartStartEndDate(input);
|
const currentPeriod = getChartStartEndDate(input);
|
||||||
const previousPeriod = getChartPrevStartEndDate({
|
const previousPeriod = getChartPrevStartEndDate({
|
||||||
range: input.range,
|
range: input.range,
|
||||||
@@ -228,7 +233,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const final: FinalChart = {
|
const final: FinalChart = {
|
||||||
events: input.events,
|
events: input.events,
|
||||||
series: series.map((serie, index) => {
|
series: series.map((serie) => {
|
||||||
const previousSerie = previousSeries?.find(
|
const previousSerie = previousSeries?.find(
|
||||||
(item) => item.name === serie.name
|
(item) => item.name === serie.name
|
||||||
);
|
);
|
||||||
@@ -321,9 +326,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
final.metrics.average,
|
final.metrics.average,
|
||||||
round(
|
round(
|
||||||
average(
|
average(
|
||||||
final.series.map(
|
final.series.map((item) => item.metrics.previous.average?.value ?? 0)
|
||||||
(item) => item.metrics.previous.average?.value ?? 0
|
|
||||||
)
|
|
||||||
),
|
),
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
@@ -350,8 +353,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return final;
|
return final;
|
||||||
}),
|
}
|
||||||
});
|
|
||||||
|
|
||||||
export function getPreviousMetric(
|
export function getPreviousMetric(
|
||||||
current: number,
|
current: number,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function objectToZodEnums<K extends string>(
|
|||||||
export const mapKeys = objectToZodEnums;
|
export const mapKeys = objectToZodEnums;
|
||||||
|
|
||||||
export const zChartEvent = z.object({
|
export const zChartEvent = z.object({
|
||||||
id: z.string(),
|
id: z.string().optional(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
displayName: z.string().optional(),
|
displayName: z.string().optional(),
|
||||||
property: z.string().optional(),
|
property: z.string().optional(),
|
||||||
@@ -34,7 +34,7 @@ export const zChartEvent = z.object({
|
|||||||
]),
|
]),
|
||||||
filters: z.array(
|
filters: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string().optional(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
operator: z.enum(objectToZodEnums(operators)),
|
operator: z.enum(objectToZodEnums(operators)),
|
||||||
value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())),
|
value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())),
|
||||||
@@ -42,7 +42,7 @@ export const zChartEvent = z.object({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
export const zChartBreakdown = z.object({
|
export const zChartBreakdown = z.object({
|
||||||
id: z.string(),
|
id: z.string().optional(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -59,6 +59,9 @@ importers:
|
|||||||
'@openpanel/trpc':
|
'@openpanel/trpc':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/trpc
|
version: link:../../packages/trpc
|
||||||
|
'@openpanel/validation':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/validation
|
||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: ^10.45.1
|
specifier: ^10.45.1
|
||||||
version: 10.45.1
|
version: 10.45.1
|
||||||
@@ -101,6 +104,9 @@ importers:
|
|||||||
url-metadata:
|
url-metadata:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
zod:
|
||||||
|
specifier: ^3.22.4
|
||||||
|
version: 3.22.4
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@openpanel/eslint-config':
|
'@openpanel/eslint-config':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
|||||||
Reference in New Issue
Block a user