feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -1,5 +1,6 @@
import { authRouter } from './routers/auth';
import { chartRouter } from './routers/chart';
import { chatRouter } from './routers/chat';
import { clientRouter } from './routers/client';
import { dashboardRouter } from './routers/dashboard';
import { eventRouter } from './routers/event';
@@ -10,11 +11,12 @@ import { organizationRouter } from './routers/organization';
import { overviewRouter } from './routers/overview';
import { profileRouter } from './routers/profile';
import { projectRouter } from './routers/project';
import { realtimeRouter } from './routers/realtime';
import { referenceRouter } from './routers/reference';
import { reportRouter } from './routers/report';
import { sessionRouter } from './routers/session';
import { shareRouter } from './routers/share';
import { subscriptionRouter } from './routers/subscription';
import { ticketRouter } from './routers/ticket';
import { userRouter } from './routers/user';
import { createTRPCRouter } from './trpc';
/**
@@ -32,15 +34,17 @@ export const appRouter = createTRPCRouter({
client: clientRouter,
event: eventRouter,
profile: profileRouter,
session: sessionRouter,
share: shareRouter,
onboarding: onboardingRouter,
reference: referenceRouter,
ticket: ticketRouter,
notification: notificationRouter,
integration: integrationRouter,
auth: authRouter,
subscription: subscriptionRouter,
overview: overviewRouter,
realtime: realtimeRouter,
chat: chatRouter,
});
// export type definition of API

View File

@@ -9,6 +9,7 @@ import {
hashPassword,
invalidateSession,
setSessionTokenCookie,
validateSessionToken,
verifyPasswordHash,
} from '@openpanel/auth';
import { generateSecureId } from '@openpanel/common/server/id';
@@ -26,7 +27,6 @@ import {
zSignInShare,
zSignUpEmail,
} from '@openpanel/validation';
import * as bcrypt from 'bcrypt';
import { z } from 'zod';
import { TRPCAccessError, TRPCNotFoundError } from '../errors';
import {
@@ -216,21 +216,17 @@ export const authRouter = createTRPCRouter({
throw TRPCAccessError('Incorrect email or password');
}
} else {
const validPassword = await bcrypt.compare(
password,
user.account.password ?? '',
throw TRPCAccessError(
'Reset your password, old password has expired',
);
if (!validPassword) {
throw TRPCAccessError('Incorrect email or password');
}
}
}
const token = generateSessionToken();
const session = await createSession(token, user.id);
console.log('session', session);
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
console.log('ctx.setCookie', ctx.setCookie);
return {
type: 'email',
};
@@ -318,7 +314,7 @@ export const authRouter = createTRPCRouter({
await sendEmail('reset-password', {
to: input.email,
data: {
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/reset-password?token=${token}`,
url: `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/reset-password?token=${token}`,
},
});
@@ -328,6 +324,26 @@ export const authRouter = createTRPCRouter({
return ctx.session;
}),
extendSession: publicProcedure.mutation(async ({ ctx }) => {
if (!ctx.session.session || !ctx.cookies.session) {
return { extended: false };
}
const token = ctx.cookies.session;
const session = await validateSessionToken(token);
if (session.session) {
// Re-set the cookie with updated expiration
setSessionTokenCookie(ctx.setCookie, token, session.session.expiresAt);
return {
extended: true,
expiresAt: session.session.expiresAt,
};
}
return { extended: false };
}),
signInShare: publicProcedure
.use(
rateLimitMiddleware({

View File

@@ -1,6 +1,6 @@
import * as mathjs from 'mathjs';
import { last, pluck, reverse, uniq } from 'ramda';
import { escape } from 'sqlstring';
import sqlstring from 'sqlstring';
import type { ISerieDataItem } from '@openpanel/common';
import {
@@ -162,11 +162,11 @@ export async function getFunnelData({
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = ${escape(event.name)}`;
sb.where.name = `name = ${sqlstring.escape(event.name)}`;
return getWhere().replace('WHERE ', '');
});
const commonWhere = `project_id = ${escape(projectId)} AND
const commonWhere = `project_id = ${sqlstring.escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}'`;
@@ -177,7 +177,7 @@ export async function getFunnelData({
${funnelGroup[0] === 'session_id' ? '' : `LEFT JOIN (SELECT profile_id, id FROM sessions WHERE ${commonWhere}) AS s ON s.id = e.session_id`}
WHERE
${commonWhere} AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
name IN (${payload.events.map((event) => sqlstring.escape(event.name)).join(', ')})
GROUP BY ${funnelGroup[0]}`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;

View File

@@ -1,5 +1,5 @@
import { flatten, map, pipe, prop, range, sort, uniq } from 'ramda';
import { escape } from 'sqlstring';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import {
@@ -52,6 +52,80 @@ function utc(date: string | Date) {
const cacher = cacheMiddleware(60);
export const chartRouter = createTRPCRouter({
projectCard: protectedProcedure
.use(cacheMiddleware(60 * 5))
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input: { projectId } }) => {
const chartPromise = chQuery<{ value: number; date: Date }>(
`SELECT
uniqHLL12(profile_id) as value,
toStartOfDay(created_at) as date
FROM ${TABLE_NAMES.sessions}
WHERE
sign = 1 AND
project_id = ${sqlstring.escape(projectId)} AND
created_at >= now() - interval '3 month'
GROUP BY date
ORDER BY date ASC
WITH FILL FROM toStartOfDay(now() - interval '1 month')
TO toStartOfDay(now())
STEP INTERVAL 1 day
`,
);
const metricsPromise = clix(ch)
.select<{
months_3: number;
months_3_prev: number;
month: number;
day: number;
day_prev: number;
}>([
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(3)), profile_id, null)) AS months_3',
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(6)) AND created_at < (now() - toIntervalMonth(3)), profile_id, null)) AS months_3_prev',
'uniqHLL12(if(created_at >= (now() - toIntervalMonth(1)), profile_id, null)) AS month',
'uniqHLL12(if(created_at >= (now() - toIntervalDay(1)), profile_id, null)) AS day',
'uniqHLL12(if(created_at >= (now() - toIntervalDay(2)) AND created_at < (now() - toIntervalDay(1)), profile_id, null)) AS day_prev',
])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId)
.where('created_at', '>=', clix.exp('now() - toIntervalMonth(6)'))
.execute();
const [chart, [metrics]] = await Promise.all([
chartPromise,
metricsPromise,
]);
const change =
metrics && metrics.months_3_prev > 0 && metrics.months_3 > 0
? Math.round(
((metrics.months_3 - metrics.months_3_prev) /
metrics.months_3_prev) *
100,
)
: null;
const trend =
change === null
? { direction: 'neutral' as const, percentage: null as number | null }
: change > 0
? { direction: 'up' as const, percentage: change }
: change < 0
? { direction: 'down' as const, percentage: Math.abs(change) }
: { direction: 'neutral' as const, percentage: 0 };
return {
chart: chart.map((d) => ({ ...d, date: new Date(d.date) })),
metrics,
trend,
};
}),
events: protectedProcedure
.input(
z.object({
@@ -61,7 +135,7 @@ export const chartRouter = createTRPCRouter({
.query(async ({ input: { projectId } }) => {
const [events, meta] = await Promise.all([
chQuery<{ name: string; count: number }>(
`SELECT name, count(name) as count FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${escape(projectId)} GROUP BY name ORDER BY count DESC, name ASC`,
`SELECT name, count(name) as count FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${sqlstring.escape(projectId)} GROUP BY name ORDER BY count DESC, name ASC`,
),
getEventMetasCached(projectId),
]);
@@ -376,9 +450,9 @@ export const chartRouter = createTRPCRouter({
const whereEventNameIs = (event: string[]) => {
if (event.length === 1) {
return `name = ${escape(event[0])}`;
return `name = ${sqlstring.escape(event[0])}`;
}
return `name IN (${event.map((e) => escape(e)).join(',')})`;
return `name IN (${event.map((e) => sqlstring.escape(e)).join(',')})`;
};
const cohortQuery = `
@@ -390,7 +464,7 @@ export const chartRouter = createTRPCRouter({
${sqlToStartOf}(created_at) AS cohort_interval
FROM ${TABLE_NAMES.cohort_events_mv}
WHERE ${whereEventNameIs(firstEvent)}
AND project_id = ${escape(projectId)}
AND project_id = ${sqlstring.escape(projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}')
),
last_event AS
@@ -401,7 +475,7 @@ export const chartRouter = createTRPCRouter({
toDate(created_at) AS event_date
FROM cohort_events_mv
WHERE ${whereEventNameIs(secondEvent)}
AND project_id = ${escape(projectId)}
AND project_id = ${sqlstring.escape(projectId)}
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}') + INTERVAL ${diffInterval} ${sqlInterval}
),
retention_matrix AS

View File

@@ -0,0 +1,20 @@
import { z } from 'zod';
import { db } from '@openpanel/db';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const chatRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
return db.chat.findFirst({
where: {
projectId: input.projectId,
},
orderBy: {
createdAt: 'desc',
},
});
}),
});

View File

@@ -10,6 +10,19 @@ import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input }) => {
return db.client.findMany({
where: {
projectId: input.projectId,
},
});
}),
update: protectedProcedure
.input(
z.object({

View File

@@ -3,6 +3,7 @@ import { z } from 'zod';
import {
db,
getDashboardById,
getDashboardsByProjectId,
getId,
getProjectById,
@@ -23,6 +24,31 @@ export const dashboardRouter = createTRPCRouter({
.query(({ input }) => {
return getDashboardsByProjectId(input.projectId);
}),
byId: protectedProcedure
.input(
z.object({
id: z.string(),
projectId: z.string(),
}),
)
.query(async ({ input, ctx }) => {
const access = await getProjectAccess({
projectId: input.projectId,
userId: ctx.session.userId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
const dashboard = await getDashboardById(input.id, input.projectId);
if (!dashboard) {
throw TRPCNotFoundError('Dashboard not found');
}
return dashboard;
}),
create: protectedProcedure
.input(
z.object({

View File

@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
import { escape } from 'sqlstring';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import {
@@ -117,6 +117,7 @@ export const eventRouter = createTRPCRouter({
z.object({
projectId: z.string(),
profileId: z.string().optional(),
sessionId: z.string().optional(),
cursor: z.string().optional(),
filters: z.array(zChartEventFilter).default([]),
startDate: z.date().optional(),
@@ -130,10 +131,15 @@ export const eventRouter = createTRPCRouter({
take: 50,
cursor: input.cursor ? new Date(input.cursor) : undefined,
select: {
profile: true,
properties: true,
sessionId: true,
deviceId: true,
profileId: true,
referrerName: true,
referrerType: true,
referrer: true,
origin: true,
},
});
@@ -159,7 +165,7 @@ export const eventRouter = createTRPCRouter({
const lastItem = items[items.length - 1];
return {
items,
data: items,
meta: {
next:
items.length === 50 && lastItem
@@ -168,39 +174,80 @@ export const eventRouter = createTRPCRouter({
},
};
}),
conversionNames: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
return getConversionEventNames(projectId);
}),
conversions: protectedProcedure
.input(
z.object({
projectId: z.string(),
cursor: z.string().optional(),
startDate: z.date().optional(),
endDate: z.date().optional(),
}),
)
.query(async ({ input: { projectId, cursor } }) => {
const conversions = await getConversionEventNames(projectId);
.query(async ({ input }) => {
const conversions = await getConversionEventNames(input.projectId);
if (conversions.length === 0) {
return {
items: [],
data: [],
meta: {
next: null,
},
};
}
const items = await getEvents(
`SELECT * FROM ${TABLE_NAMES.events} WHERE ${cursor ? `created_at <= '${formatClickhouseDate(cursor)}' AND` : ''} project_id = ${escape(projectId)} AND name IN (${conversions.map((c) => escape(c.name)).join(', ')}) ORDER BY toDate(created_at) DESC, created_at DESC LIMIT 50;`,
{
const items = await getEventList({
...input,
take: 50,
cursor: input.cursor ? new Date(input.cursor) : undefined,
select: {
profile: true,
meta: true,
properties: true,
sessionId: true,
deviceId: true,
profileId: true,
referrerName: true,
referrerType: true,
referrer: true,
origin: true,
},
);
custom: (sb) => {
sb.where.name = `name IN (${conversions.map((event) => sqlstring.escape(event.name)).join(',')})`;
},
});
// Hacky join to get profile for entire session
// TODO: Replace this with a join on the session table
const map = new Map<string, IServiceProfile>(); // sessionId -> profileId
for (const item of items) {
if (item.sessionId && item.profile?.isExternal === true) {
map.set(item.sessionId, item.profile);
}
}
for (const item of items) {
const profile = map.get(item.sessionId);
if (profile && (item.profile?.isExternal === false || !item.profile)) {
item.profile = clone(profile);
if (item?.profile?.firstName) {
item.profile.firstName = `* ${item.profile.firstName}`;
}
}
}
const lastItem = items[items.length - 1];
return {
items,
data: items,
meta: {
next: lastItem ? lastItem.createdAt.toISOString() : null,
next:
items.length === 50 && lastItem
? lastItem.createdAt.toISOString()
: null,
},
};
}),
@@ -243,12 +290,12 @@ export const eventRouter = createTRPCRouter({
path: string;
created_at: string;
}>(
`SELECT * FROM ${TABLE_NAMES.events_bots} WHERE project_id = ${escape(projectId)} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${(cursor ?? 0) * limit}`,
`SELECT * FROM ${TABLE_NAMES.events_bots} WHERE project_id = ${sqlstring.escape(projectId)} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${(cursor ?? 0) * limit}`,
),
chQuery<{
count: number;
}>(
`SELECT count(*) as count FROM ${TABLE_NAMES.events_bots} WHERE project_id = ${escape(projectId)}`,
`SELECT count(*) as count FROM ${TABLE_NAMES.events_bots} WHERE project_id = ${sqlstring.escape(projectId)}`,
),
]);
@@ -303,7 +350,7 @@ export const eventRouter = createTRPCRouter({
)
.query(async ({ input }) => {
const res = await chQuery<{ origin: string }>(
`SELECT DISTINCT origin FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(
`SELECT DISTINCT origin FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(
input.projectId,
)} AND origin IS NOT NULL AND origin != '' AND toDate(created_at) > now() - INTERVAL 30 DAY ORDER BY origin ASC`,
);

View File

@@ -37,7 +37,7 @@ export const notificationRouter = createTRPCRouter({
},
},
},
take: 100,
take: 5000,
});
}),
rules: protectedProcedure

View File

@@ -33,10 +33,7 @@ async function createOrGetOrganization(
},
});
if (
process.env.NEXT_PUBLIC_SELF_HOSTED !== 'true' &&
!process.env.SELF_HOSTED
) {
if (process.env.VITE_SELF_HOSTED !== 'true' && !process.env.SELF_HOSTED) {
await addTrialEndingSoonJob(
organization.id,
1000 * 60 * 60 * 24 * TRIAL_DURATION_IN_DAYS * 0.9,
@@ -64,7 +61,6 @@ export const onboardingRouter = createTRPCRouter({
if (members.length > 0) {
return {
canSkip: true,
url: `/${members[0]?.organizationId}`,
};
}
@@ -77,11 +73,10 @@ export const onboardingRouter = createTRPCRouter({
if (projectAccess.length > 0) {
return {
canSkip: true,
url: `/${projectAccess[0]?.organizationId}/${projectAccess[0]?.projectId}`,
};
}
return { canSkip: false, url: null };
return { canSkip: false };
}),
project: protectedProcedure
.input(zOnboardingProject)

View File

@@ -1,6 +1,14 @@
import { z } from 'zod';
import { connectUserToOrganization, db } from '@openpanel/db';
import {
connectUserToOrganization,
db,
getInviteById,
getInvites,
getMembers,
getOrganizationById,
getOrganizations,
} from '@openpanel/db';
import { zEditOrganization, zInviteUser } from '@openpanel/validation';
import { generateSecureId } from '@openpanel/common/server/id';
@@ -8,9 +16,24 @@ import { sendEmail } from '@openpanel/email';
import { addDays } from 'date-fns';
import { getOrganizationAccess } from '../access';
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
rateLimitMiddleware,
} from '../trpc';
export const organizationRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
return getOrganizationById(input.organizationId);
}),
list: protectedProcedure.query(async ({ ctx }) => {
return getOrganizations(ctx.session.userId);
}),
update: protectedProcedure
.input(zEditOrganization)
.mutation(async ({ input, ctx }) => {
@@ -116,7 +139,7 @@ export const organizationRouter = createTRPCRouter({
await sendEmail('invite', {
to: email,
data: {
url: `${process.env.NEXT_PUBLIC_DASHBOARD_URL}/onboarding?inviteId=${invite.id}`,
url: `${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/onboarding?inviteId=${invite.id}`,
organizationName: invite.organization.name,
},
});
@@ -240,4 +263,31 @@ export const organizationRouter = createTRPCRouter({
}),
]);
}),
members: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
return getMembers(input.organizationId);
}),
invitations: protectedProcedure
.input(z.object({ organizationId: z.string() }))
.query(async ({ input }) => {
return getInvites(input.organizationId);
}),
getInvite: publicProcedure
.use(
rateLimitMiddleware({
max: 5,
windowMs: 30_000,
}),
)
.input(z.object({ inviteId: z.string().optional() }))
.query(async ({ input }) => {
if (!input.inviteId) {
throw TRPCBadRequestError('Invite ID is required');
}
return getInviteById(input.inviteId);
}),
});

View File

@@ -1,4 +1,5 @@
import {
eventBuffer,
getChartPrevStartEndDate,
getChartStartEndDate,
getOrganizationSubscriptionChartEndDate,
@@ -76,6 +77,11 @@ function getCurrentAndPrevious<
}
export const overviewRouter = createTRPCRouter({
liveVisitors: publicProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
return eventBuffer.getActiveVisitorCount(input.projectId);
}),
stats: publicProcedure
.input(
zGetMetricsInput.omit({ startDate: true, endDate: true }).extend({

View File

@@ -1,23 +1,62 @@
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { escape } from 'sqlstring';
import sqlstring from 'sqlstring';
import { z } from 'zod';
import {
TABLE_NAMES,
chQuery,
createSqlBuilder,
getProfileByIdCached,
getProfileList,
getProfileListCount,
getProfileMetrics,
getProfiles,
} from '@openpanel/db';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const profileRouter = createTRPCRouter({
byId: protectedProcedure
.input(z.object({ profileId: z.string(), projectId: z.string() }))
.query(async ({ input: { profileId, projectId } }) => {
return getProfileByIdCached(profileId, projectId);
}),
metrics: protectedProcedure
.input(z.object({ profileId: z.string(), projectId: z.string() }))
.query(async ({ input: { profileId, projectId } }) => {
return getProfileMetrics(profileId, projectId);
}),
activity: protectedProcedure
.input(z.object({ profileId: z.string(), projectId: z.string() }))
.query(async ({ input: { profileId, projectId } }) => {
return chQuery<{ count: number; date: string }>(
`SELECT count(*) as count, toStartOfDay(created_at) as date FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(projectId)} and profile_id = ${sqlstring.escape(profileId)} GROUP BY date ORDER BY date DESC`,
);
}),
mostEvents: protectedProcedure
.input(z.object({ profileId: z.string(), projectId: z.string() }))
.query(async ({ input: { profileId, projectId } }) => {
return chQuery<{ count: number; name: string }>(
`SELECT count(*) as count, name FROM ${TABLE_NAMES.events} WHERE name NOT IN ('screen_view', 'session_start', 'session_end') AND project_id = ${sqlstring.escape(projectId)} and profile_id = ${sqlstring.escape(profileId)} GROUP BY name ORDER BY count DESC`,
);
}),
popularRoutes: protectedProcedure
.input(z.object({ profileId: z.string(), projectId: z.string() }))
.query(async ({ input: { profileId, projectId } }) => {
return chQuery<{ count: number; path: string }>(
`SELECT count(*) as count, path FROM ${TABLE_NAMES.events} WHERE name = 'screen_view' AND project_id = ${sqlstring.escape(projectId)} and profile_id = ${sqlstring.escape(profileId)} GROUP BY path ORDER BY count DESC LIMIT 10`,
);
}),
properties: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from ${TABLE_NAMES.profiles} where project_id = ${escape(projectId)};`,
`SELECT distinct mapKeys(properties) as keys from ${TABLE_NAMES.profiles} where project_id = ${sqlstring.escape(projectId)};`,
);
const properties = events
@@ -41,11 +80,21 @@ export const profileRouter = createTRPCRouter({
cursor: z.number().optional(),
take: z.number().default(50),
search: z.string().optional(),
// filters: z.array(zChartEventFilter).default([]),
isExternal: z.boolean().optional(),
}),
)
.query(async ({ input: { projectId, cursor, take, search } }) => {
return getProfileList({ projectId, cursor, take, search });
.query(async ({ input }) => {
const [data, count] = await Promise.all([
getProfileList(input),
getProfileListCount(input),
]);
return {
data,
meta: {
count,
pageCount: input.take,
},
};
}),
powerUsers: protectedProcedure
@@ -54,28 +103,42 @@ export const profileRouter = createTRPCRouter({
projectId: z.string(),
cursor: z.number().optional(),
take: z.number().default(50),
// filters: z.array(zChartEventFilter).default([]),
}),
)
.query(async ({ input: { projectId, cursor, take } }) => {
const res = await chQuery<{ profile_id: string; count: number }>(
`SELECT profile_id, count(*) as count from ${TABLE_NAMES.events} where profile_id != '' and project_id = ${escape(projectId)} group by profile_id order by count() DESC LIMIT ${take} ${cursor ? `OFFSET ${cursor * take}` : ''}`,
`
SELECT profile_id, count(*) as count
FROM ${TABLE_NAMES.events}
WHERE
profile_id != ''
AND project_id = ${sqlstring.escape(projectId)}
GROUP BY profile_id
ORDER BY count() DESC
LIMIT ${take} ${cursor ? `OFFSET ${cursor * take}` : ''}`,
);
const profiles = await getProfiles(
res.map((r) => r.profile_id),
projectId,
);
return (
res
.map((item) => {
return {
count: item.count,
...(profiles.find((p) => p.id === item.profile_id)! ?? {}),
};
})
// Make sure we return actual profiles
.filter((item) => item.id)
);
const data = res
.map((item) => {
return {
count: item.count,
...(profiles.find((p) => p.id === item.profile_id)! ?? {}),
};
})
// Make sure we return actual profiles
.filter((item) => item.id);
return {
data,
meta: {
count: data.length,
pageCount: take,
},
};
}),
values: protectedProcedure
@@ -88,9 +151,9 @@ export const profileRouter = createTRPCRouter({
.query(async ({ input: { property, projectId } }) => {
const { sb, getSql } = createSqlBuilder();
sb.from = TABLE_NAMES.profiles;
sb.where.project_id = `project_id = ${escape(projectId)}`;
sb.where.project_id = `project_id = ${sqlstring.escape(projectId)}`;
if (property.startsWith('properties.')) {
sb.select.values = `distinct arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${escape(
sb.select.values = `distinct arrayMap(x -> trim(x), mapValues(mapExtractKeyLike(properties, ${sqlstring.escape(
property.replace(/^properties\./, '').replace('.*.', '.%.'),
)}))) as values`;
} else {

View File

@@ -6,19 +6,38 @@ import { hashPassword } from '@openpanel/common/server';
import {
type Prisma,
db,
getClientById,
getClientByIdCached,
getId,
getProjectByIdCached,
getProjectWithClients,
getProjectsByOrganizationId,
} from '@openpanel/db';
import { zOnboardingProject, zProject } from '@openpanel/validation';
import { addDays, addHours } from 'date-fns';
import { addHours } from 'date-fns';
import { getProjectAccess } from '../access';
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const projectRouter = createTRPCRouter({
getProjectWithClients: protectedProcedure
.input(
z.object({
projectId: z.string(),
}),
)
.query(async ({ input: { projectId }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
return getProjectWithClients(projectId);
}),
list: protectedProcedure
.input(
z.object({

View File

@@ -0,0 +1,143 @@
import { z } from 'zod';
import {
type EventMeta,
TABLE_NAMES,
ch,
chQuery,
clix,
db,
formatClickhouseDate,
getEventList,
} from '@openpanel/db';
import { subMinutes } from 'date-fns';
import sqlstring from 'sqlstring';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const realtimeRouter = createTRPCRouter({
coordinates: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
const res = await chQuery<{
city: string;
country: string;
long: number;
lat: number;
}>(
`SELECT DISTINCT country, city, longitude as long, latitude as lat FROM ${TABLE_NAMES.events} WHERE project_id = ${sqlstring.escape(input.projectId)} AND created_at >= '${formatClickhouseDate(subMinutes(new Date(), 30))}' ORDER BY created_at DESC`,
);
return res;
}),
activeSessions: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
return getEventList({
projectId: input.projectId,
take: 30,
select: {
name: true,
path: true,
origin: true,
referrer: true,
referrerName: true,
referrerType: true,
country: true,
device: true,
os: true,
browser: true,
createdAt: true,
profile: true,
meta: true,
},
});
}),
paths: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
const res = await clix(ch)
.select<{
origin: string;
path: string;
count: number;
avg_duration: number;
}>([
'origin',
'path',
'COUNT(*) as count',
'round(avg(duration)/1000, 2) as avg_duration',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('path', '!=', '')
.where(
'created_at',
'>=',
formatClickhouseDate(subMinutes(new Date(), 30)),
)
.groupBy(['path', 'origin'])
.orderBy('count', 'DESC')
.limit(100)
.execute();
return res;
}),
referrals: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
const res = await clix(ch)
.select<{
referrer_name: string;
count: number;
avg_duration: number;
}>([
'referrer_name',
'COUNT(*) as count',
'round(avg(duration)/1000, 2) as avg_duration',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('referrer_name', 'IS NOT NULL')
.where(
'created_at',
'>=',
formatClickhouseDate(subMinutes(new Date(), 30)),
)
.groupBy(['referrer_name'])
.orderBy('count', 'DESC')
.limit(100)
.execute();
return res;
}),
geo: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input }) => {
const res = await clix(ch)
.select<{
country: string;
city: string;
count: number;
avg_duration: number;
}>([
'country',
'city',
'COUNT(*) as count',
'round(avg(duration)/1000, 2) as avg_duration',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where(
'created_at',
'>=',
formatClickhouseDate(subMinutes(new Date(), 30)),
)
.groupBy(['country', 'city'])
.orderBy('count', 'DESC')
.limit(100)
.execute();
return res;
}),
});

View File

@@ -1,11 +1,6 @@
import { z } from 'zod';
import {
db,
getChartStartEndDate,
getReferences,
getSettingsForProject,
} from '@openpanel/db';
import { db, getChartStartEndDate, getSettingsForProject } from '@openpanel/db';
import { zCreateReference, zRange } from '@openpanel/validation';
import { getProjectAccess } from '../access';
@@ -13,6 +8,32 @@ import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const referenceRouter = createTRPCRouter({
getReferences: protectedProcedure
.input(
z.object({
projectId: z.string(),
cursor: z.number().optional(),
}),
)
.query(async ({ input: { projectId, cursor }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
return db.reference.findMany({
where: {
projectId,
},
take: 50,
skip: cursor ? cursor * 50 : 0,
});
}),
create: protectedProcedure
.input(zCreateReference)
.mutation(
@@ -27,6 +48,38 @@ export const referenceRouter = createTRPCRouter({
});
},
),
update: protectedProcedure
.input(
z.object({
id: z.string(),
title: z.string(),
description: z.string().nullish(),
datetime: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const existing = await db.reference.findUniqueOrThrow({
where: { id: input.id },
});
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: existing.projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
return db.reference.update({
where: { id: input.id },
data: {
title: input.title,
description: input.description ?? null,
date: new Date(input.datetime),
},
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input: { id }, ctx }) => {
@@ -63,7 +116,7 @@ export const referenceRouter = createTRPCRouter({
.query(async ({ input: { projectId, ...input } }) => {
const { timezone } = await getSettingsForProject(projectId);
const { startDate, endDate } = getChartStartEndDate(input, timezone);
return getReferences({
return db.reference.findMany({
where: {
projectId,
date: {

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { db } from '@openpanel/db';
import { db, getReportById, getReportsByDashboardId } from '@openpanel/db';
import { zReportInput } from '@openpanel/validation';
import { getProjectAccess } from '../access';
@@ -8,6 +8,16 @@ import { TRPCAccessError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const reportRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
dashboardId: z.string(),
projectId: z.string(),
}),
)
.query(async ({ input: { dashboardId, projectId }, ctx }) => {
return getReportsByDashboardId(dashboardId);
}),
create: protectedProcedure
.input(
z.object({
@@ -125,4 +135,127 @@ export const reportRouter = createTRPCRouter({
},
});
}),
get: protectedProcedure
.input(
z.object({
reportId: z.string(),
}),
)
.query(async ({ input: { reportId }, ctx }) => {
return getReportById(reportId);
}),
updateLayout: protectedProcedure
.input(
z.object({
reportId: z.string(),
layout: z.object({
x: z.number(),
y: z.number(),
w: z.number(),
h: z.number(),
minW: z.number().optional(),
minH: z.number().optional(),
maxW: z.number().optional(),
maxH: z.number().optional(),
}),
}),
)
.mutation(async ({ input: { reportId, layout }, ctx }) => {
const report = await db.report.findUniqueOrThrow({
where: {
id: reportId,
},
});
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: report.projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
// Upsert the layout (create if doesn't exist, update if it does)
return db.reportLayout.upsert({
where: {
reportId: reportId,
},
create: {
reportId: reportId,
x: layout.x,
y: layout.y,
w: layout.w,
h: layout.h,
minW: layout.minW,
minH: layout.minH,
maxW: layout.maxW,
maxH: layout.maxH,
},
update: {
x: layout.x,
y: layout.y,
w: layout.w,
h: layout.h,
minW: layout.minW,
minH: layout.minH,
maxW: layout.maxW,
maxH: layout.maxH,
},
});
}),
getLayouts: protectedProcedure
.input(
z.object({
dashboardId: z.string(),
projectId: z.string(),
}),
)
.query(async ({ input: { dashboardId, projectId }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
return db.reportLayout.findMany({
where: {
report: {
dashboardId: dashboardId,
},
},
include: {
report: true,
},
});
}),
resetLayout: protectedProcedure
.input(
z.object({
dashboardId: z.string(),
projectId: z.string(),
}),
)
.mutation(async ({ input: { dashboardId, projectId }, ctx }) => {
const access = await getProjectAccess({
userId: ctx.session.userId,
projectId: projectId,
});
if (!access) {
throw TRPCAccessError('You do not have access to this project');
}
// Delete all layout data for reports in this dashboard
return db.reportLayout.deleteMany({
where: {
report: {
dashboardId: dashboardId,
},
},
});
}),
});

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
import { getSessionList, sessionService } from '@openpanel/db';
import { zChartEventFilter } from '@openpanel/validation';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export function encodeCursor(cursor: {
createdAt: string;
id: string;
}): string {
const json = JSON.stringify(cursor);
return Buffer.from(json, 'utf8').toString('base64url'); // URL-safe
}
export function decodeCursor(
encoded: string,
): { createdAt: string; id: string } | null {
try {
const json = Buffer.from(encoded, 'base64url').toString('utf8');
const obj = JSON.parse(json);
if (typeof obj.createdAt === 'string' && typeof obj.id === 'string') {
return obj;
}
return null;
} catch {
return null;
}
}
export const sessionRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
profileId: z.string().optional(),
cursor: z.string().nullish(),
filters: z.array(zChartEventFilter).default([]),
startDate: z.date().optional(),
endDate: z.date().optional(),
search: z.string().optional(),
take: z.number().default(50),
}),
)
.query(async ({ input }) => {
const cursor = input.cursor ? decodeCursor(input.cursor) : null;
const data = await getSessionList({
...input,
cursor,
});
return {
data: data.items,
meta: {
next: data.meta.next ? encodeCursor(data.meta.next) : undefined,
},
};
}),
byId: protectedProcedure
.input(z.object({ sessionId: z.string(), projectId: z.string() }))
.query(async ({ input: { sessionId, projectId } }) => {
return sessionService.byId(sessionId, projectId);
}),
});

View File

@@ -4,12 +4,59 @@ import { db } from '@openpanel/db';
import { zShareOverview } from '@openpanel/validation';
import { hashPassword } from '@openpanel/auth';
import { z } from 'zod';
import { TRPCNotFoundError } from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
const uid = new ShortUniqueId({ length: 6 });
export const shareRouter = createTRPCRouter({
shareOverview: protectedProcedure
overview: protectedProcedure
.input(
z
.object({
projectId: z.string(),
})
.or(
z.object({
shareId: z.string(),
}),
),
)
.query(async ({ input, ctx }) => {
const share = await db.shareOverview.findUnique({
include: {
organization: {
select: {
name: true,
},
},
project: {
select: {
name: true,
},
},
},
where:
'projectId' in input
? {
projectId: input.projectId,
}
: {
id: input.shareId,
},
});
if (!share) {
throw TRPCNotFoundError('Share not found');
}
return {
...share,
hasAccess: !!ctx.cookies[`shared-overview-${share?.id}`],
};
}),
createOverview: protectedProcedure
.input(zShareOverview)
.mutation(async ({ input }) => {
const passwordHash = input.password

View File

@@ -75,7 +75,7 @@ export const subscriptionRouter = createTRPCRouter({
}
const checkout = await createCheckout({
priceId: input.productPriceId,
productId: input.productId,
organizationId: input.organizationId,
projectId: input.projectId ?? undefined,
user,

View File

@@ -1,37 +0,0 @@
import { SeventySevenClient } from '@seventy-seven/sdk';
import { z } from 'zod';
import { getUserById } from '@openpanel/db';
import { createTRPCRouter, protectedProcedure } from '../trpc';
const API_KEY = process.env.SEVENTY_SEVEN_API_KEY!;
const client = new SeventySevenClient(API_KEY);
export const ticketRouter = createTRPCRouter({
create: protectedProcedure
.input(
z.object({
subject: z.string(),
body: z.string(),
meta: z.record(z.string(), z.unknown()),
}),
)
.mutation(async ({ input, ctx }) => {
if (!API_KEY) {
throw new Error('Ticket system not configured');
}
const user = await getUserById(ctx.session.userId);
return client.createTicket({
subject: input.subject,
body: input.body,
meta: input.meta,
senderEmail: user?.email || 'none',
senderFullName: user?.firstName
? [user?.firstName, user?.lastName].filter(Boolean).join(' ')
: 'none',
});
}),
});

View File

@@ -5,6 +5,12 @@ import { db } from '@openpanel/db';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
export const userRouter = createTRPCRouter({
test: publicProcedure.query(async ({ ctx }) => {
const user = await db.user.findFirst();
return {
user,
};
}),
update: protectedProcedure
.input(
z.object({

View File

@@ -4,8 +4,12 @@ import { has } from 'ramda';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { COOKIE_OPTIONS, validateSessionToken } from '@openpanel/auth';
import { getRedisCache } from '@openpanel/redis';
import {
COOKIE_OPTIONS,
EMPTY_SESSION,
validateSessionToken,
} from '@openpanel/auth';
import { getCache, getRedisCache } from '@openpanel/redis';
import type { ISetCookie } from '@openpanel/validation';
import {
createTrpcRedisLimiter,
@@ -31,6 +35,7 @@ export const rateLimitMiddleware = ({
});
export async function createContext({ req, res }: CreateFastifyContextOptions) {
const cookies = (req as any).cookies as Record<string, string | undefined>;
const setCookie: ISetCookie = (key, value, options) => {
// @ts-ignore
res.setCookie(key, value, {
@@ -39,8 +44,17 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
});
};
// @ts-ignore
const session = await validateSessionToken(req.cookies?.session);
const session = cookies?.session
? await getCache(`session:${cookies?.session}`, 1000 * 60 * 5, async () => {
return validateSessionToken(cookies.session!);
})
: EMPTY_SESSION;
if (process.env.NODE_ENV !== 'production') {
await new Promise((res) =>
setTimeout(() => res(1), Math.min(Math.random() * 500, 200)),
);
}
return {
req,
@@ -49,6 +63,7 @@ export async function createContext({ req, res }: CreateFastifyContextOptions) {
// we do not get types for `setCookie` from fastify
// so define it here and be safe in routers
setCookie,
cookies,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;
@@ -88,7 +103,8 @@ const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => {
});
// Only used on protected routes
const enforceAccess = t.middleware(async ({ ctx, next, rawInput, type }) => {
const enforceAccess = t.middleware(async ({ ctx, next, type, getRawInput }) => {
const rawInput = await getRawInput();
if (type === 'mutation' && process.env.DEMO_USER_ID) {
throw new TRPCError({
code: 'UNAUTHORIZED',
@@ -124,7 +140,8 @@ const enforceAccess = t.middleware(async ({ ctx, next, rawInput, type }) => {
export const createTRPCRouter = t.router;
const loggerMiddleware = t.middleware(
async ({ ctx, next, rawInput, path, input, type }) => {
async ({ ctx, next, getRawInput, path, input, type }) => {
const rawInput = await getRawInput();
// Only log mutations
if (type === 'mutation') {
ctx.req.log.info('TRPC mutation', {
@@ -153,7 +170,8 @@ const middlewareMarker = 'middlewareMarker' as 'middlewareMarker' & {
};
export const cacheMiddleware = (cbOrTtl: number | ((input: any) => number)) =>
t.middleware(async ({ ctx, next, path, type, rawInput, input }) => {
t.middleware(async ({ ctx, next, path, type, getRawInput, input }) => {
const rawInput = await getRawInput();
if (type !== 'query') {
return next();
}